hdx-python-database 1.3.1__tar.gz → 1.3.2__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.
Files changed (37) hide show
  1. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/.config/pre-commit-config.yaml +4 -4
  2. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/.github/workflows/publish.yaml +4 -3
  3. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/.github/workflows/run-python-tests.yaml +6 -5
  4. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/PKG-INFO +1 -1
  5. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/documentation/main.md +14 -3
  6. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/pyproject.toml +12 -16
  7. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/requirements.txt +25 -20
  8. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/src/hdx/database/__init__.py +31 -14
  9. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/src/hdx/database/_version.py +2 -2
  10. hdx_python_database-1.3.2/src/hdx/database/postgresql.py +95 -0
  11. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/tests/hdx/database/conftest.py +42 -11
  12. hdx_python_database-1.3.2/tests/hdx/database/test_database.py +59 -0
  13. hdx_python_database-1.3.2/tests/hdx/database/test_postgresql.py +30 -0
  14. hdx_python_database-1.3.2/tests/hdx/database/test_recreate.py +8 -0
  15. hdx_python_database-1.3.2/tests/hdx/database/test_reflect.py +47 -0
  16. hdx_python_database-1.3.2/tests/hdx/database/test_ssh.py +80 -0
  17. hdx_python_database-1.3.1/src/hdx/database/postgresql.py +0 -41
  18. hdx_python_database-1.3.1/tests/hdx/database/test_database.py +0 -149
  19. hdx_python_database-1.3.1/tests/hdx/database/test_postgresql.py +0 -15
  20. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/.config/coveragerc +0 -0
  21. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/.config/pytest.ini +0 -0
  22. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/.config/ruff.toml +0 -0
  23. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/.gitignore +0 -0
  24. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/CONTRIBUTING.md +0 -0
  25. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/LICENSE +0 -0
  26. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/README.md +0 -0
  27. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/documentation/.readthedocs.yaml +0 -0
  28. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/documentation/pydoc-markdown.yaml +0 -0
  29. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/src/hdx/database/dburi.py +0 -0
  30. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/src/hdx/database/no_timezone.py +0 -0
  31. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/src/hdx/database/utils.py +0 -0
  32. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/src/hdx/database/views.py +0 -0
  33. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/src/hdx/database/with_timezone.py +0 -0
  34. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/tests/fixtures/test.db +0 -0
  35. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/tests/hdx/database/__init__.py +0 -0
  36. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/tests/hdx/database/dbtestdate.py +0 -0
  37. {hdx_python_database-1.3.1 → hdx_python_database-1.3.2}/tests/hdx/database/test_dburi.py +0 -0
@@ -1,14 +1,14 @@
1
1
  default_language_version:
2
- python: python3.11
2
+ python: python3.12
3
3
  repos:
4
4
  - repo: https://github.com/pre-commit/pre-commit-hooks
5
- rev: v4.5.0
5
+ rev: v4.6.0
6
6
  hooks:
7
7
  - id: trailing-whitespace
8
8
  - id: end-of-file-fixer
9
9
  - id: check-ast
10
10
  - repo: https://github.com/astral-sh/ruff-pre-commit
11
- rev: v0.4.2
11
+ rev: v0.5.6
12
12
  hooks:
13
13
  # Run the linter.
14
14
  - id: ruff
@@ -17,7 +17,7 @@ repos:
17
17
  - id: ruff-format
18
18
  args: [--config, .config/ruff.toml]
19
19
  - repo: https://github.com/astral-sh/uv-pre-commit
20
- rev: 0.1.38
20
+ rev: 0.2.33
21
21
  hooks:
22
22
  # Run the pip compile
23
23
  - id: pip-compile
@@ -9,19 +9,20 @@ jobs:
9
9
  runs-on: ubuntu-latest
10
10
 
11
11
  steps:
12
- - uses: actions/checkout@v3
12
+ - uses: actions/checkout@v4
13
13
  - name: Get history and tags for versioning to work
14
14
  run: |
15
15
  git fetch --prune --unshallow
16
16
  git fetch --depth=1 origin +refs/tags/*:refs/tags/*
17
17
  - name: Set up Python
18
- uses: actions/setup-python@v4
18
+ uses: actions/setup-python@v5
19
19
  with:
20
20
  python-version: '3.x'
21
21
  - name: Install dependencies
22
22
  run: |
23
23
  python -m pip install --upgrade pip
24
- pip install --upgrade hatch
24
+ - name: Install Hatch
25
+ uses: pypa/hatch@install
25
26
  - name: Build with hatch
26
27
  run: |
27
28
  hatch build
@@ -18,22 +18,23 @@ jobs:
18
18
  runs-on: ubuntu-latest
19
19
 
20
20
  steps:
21
- - uses: actions/checkout@v3
21
+ - uses: actions/checkout@v4
22
22
  - name: Set up Python
23
- uses: actions/setup-python@v4
23
+ uses: actions/setup-python@v5
24
24
  with:
25
25
  python-version: '3.x'
26
26
  - name: Install dependencies
27
27
  run: |
28
28
  python -m pip install --upgrade pip
29
- pip install --upgrade hatch
29
+ - name: Install Hatch
30
+ uses: pypa/hatch@install
30
31
  - name: Test with hatch/pytest
31
32
  run: |
32
- hatch run test:test
33
+ hatch test
33
34
  - name: Check styling
34
35
  if: always()
35
36
  run: |
36
- hatch run lint:style
37
+ hatch fmt --check
37
38
  - name: Publish Unit Test Results
38
39
  uses: EnricoMi/publish-unit-test-result-action@v2
39
40
  if: always()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: hdx-python-database
3
- Version: 1.3.1
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>
@@ -105,6 +105,10 @@ maps don't work with reflection and hence `db_has_tz` will be ignored ie.
105
105
  there will be no conversion between Python datetimes with timezones to
106
106
  timezoneless database columns.
107
107
 
108
+ A PostgreSQL database can be restored from a file generated by the `pg_dump`
109
+ command line utility by supplying `pg_restore_file` with the path to the file
110
+ to be restored.
111
+
108
112
  There is an option to wipe and create an empty schema in the database by
109
113
  setting `recreate_schema` to `True` and setting a `schema_name` ("public" is
110
114
  the default).
@@ -178,9 +182,16 @@ View preparation must be done before calling Base.metadata.create_all.
178
182
 
179
183
  ## PostgreSQL specific
180
184
 
181
- There is a PostgreSQL specific call that only returns when the PostgreSQL database
182
- is available:
185
+ There is a PostgreSQL specific call that only returns when the PostgreSQL
186
+ database becomes available:
183
187
 
184
188
  # Wait until PostgreSQL is up
185
189
  # Library should be installed with hdx-python-database[postgresql]
186
- wait_for_postgresql("mydatabase", "myserver", 5432, "myuser", "mypass")
190
+ db_uri = "postgresql+psycopg://myuser:mypass@1.2.3.4:12345/mydatabase"
191
+ wait_for_postgresql(db_uri)
192
+
193
+ The database can be restored from a file generated by `pg_dump` like this:
194
+
195
+ db_uri = "postgresql+psycopg://myuser:mypass@1.2.3.4:12345/mydatabase"
196
+ pg_restore_file = "backup.pg_restore"
197
+ assert restore_from_pgfile(db_uri, pg_restore_file) == "WORKED!"
@@ -75,25 +75,21 @@ version_scheme = "python-simplified-semver"
75
75
 
76
76
  # Tests
77
77
 
78
- [tool.hatch.envs.test]
78
+ [tool.hatch.envs.hatch-test]
79
79
  features = ["sshtunnel", "postgresql", "test"]
80
80
 
81
- [tool.hatch.envs.test.scripts]
82
- test = """
81
+ [[tool.hatch.envs.hatch-test.matrix]]
82
+ python = ["3.12"]
83
+
84
+ [tool.hatch.envs.hatch-test.scripts]
85
+ run = """
83
86
  pytest -c .config/pytest.ini --rootdir=. --junitxml=test-results.xml \
84
87
  --cov --cov-config=.config/coveragerc --no-cov-on-fail \
85
- --cov-report=lcov --cov-report=term-missing
88
+ --cov-report=lcov --cov-report=term-missing -p no:randomly
86
89
  """
87
90
 
88
- [[tool.hatch.envs.test.matrix]]
89
- python = ["3.12"]
90
-
91
- [tool.hatch.envs.lint]
92
- detached = true
93
- dependencies = ["ruff"]
94
-
95
- [tool.hatch.envs.lint.scripts]
96
- style = [
97
- "ruff check --config .config/ruff.toml --diff {args:.}",
98
- "ruff format --config .config/ruff.toml --diff {args:.}",
99
- ]
91
+ [tool.hatch.envs.hatch-static-analysis.scripts]
92
+ format-check = ["ruff format --config .config/ruff.toml --check --diff {args:.}",]
93
+ format-fix = ["ruff format --config .config/ruff.toml {args:.}",]
94
+ lint-check = ["ruff check --config .config/ruff.toml {args:.}",]
95
+ lint-fix = ["ruff check --config .config/ruff.toml --fix {args:.}",]
@@ -1,57 +1,62 @@
1
1
  # This file was autogenerated by uv via the following command:
2
2
  # uv pip compile pyproject.toml --resolver=backtracking --all-extras -o requirements.txt
3
- bcrypt==4.1.3
3
+ bcrypt==4.2.0
4
4
  # via paramiko
5
- cffi==1.16.0
5
+ cffi==1.17.0
6
6
  # via
7
7
  # cryptography
8
8
  # pynacl
9
9
  cfgv==3.4.0
10
10
  # via pre-commit
11
- coverage==7.5.1
11
+ coverage==7.6.1
12
12
  # via pytest-cov
13
- cryptography==42.0.7
13
+ cryptography==43.0.0
14
14
  # via paramiko
15
15
  distlib==0.3.8
16
16
  # via virtualenv
17
- filelock==3.14.0
17
+ filelock==3.15.4
18
18
  # via virtualenv
19
19
  greenlet==3.0.3
20
20
  # via sqlalchemy
21
- identify==2.5.36
21
+ identify==2.6.0
22
22
  # via pre-commit
23
23
  iniconfig==2.0.0
24
24
  # via pytest
25
- nodeenv==1.8.0
25
+ nodeenv==1.9.1
26
26
  # via pre-commit
27
- packaging==24.0
27
+ packaging==24.1
28
28
  # via pytest
29
29
  paramiko==3.4.0
30
30
  # via sshtunnel
31
- platformdirs==4.2.1
31
+ platformdirs==4.2.2
32
32
  # via virtualenv
33
33
  pluggy==1.5.0
34
34
  # via pytest
35
- pre-commit==3.7.0
36
- psycopg==3.1.18
37
- psycopg-binary==3.1.18
35
+ pre-commit==3.8.0
36
+ # via hdx-python-database (pyproject.toml)
37
+ psycopg==3.2.1
38
+ # via hdx-python-database (pyproject.toml)
39
+ psycopg-binary==3.2.1
38
40
  # via psycopg
39
41
  pycparser==2.22
40
42
  # via cffi
41
43
  pynacl==1.5.0
42
44
  # via paramiko
43
- pytest==8.2.0
44
- # via pytest-cov
45
+ pytest==8.3.2
46
+ # via
47
+ # hdx-python-database (pyproject.toml)
48
+ # pytest-cov
45
49
  pytest-cov==5.0.0
46
- pyyaml==6.0.1
50
+ # via hdx-python-database (pyproject.toml)
51
+ pyyaml==6.0.2
47
52
  # via pre-commit
48
- setuptools==69.5.1
49
- # via nodeenv
50
- sqlalchemy==2.0.30
53
+ sqlalchemy==2.0.32
54
+ # via hdx-python-database (pyproject.toml)
51
55
  sshtunnel==0.4.0
52
- typing-extensions==4.11.0
56
+ # via hdx-python-database (pyproject.toml)
57
+ typing-extensions==4.12.2
53
58
  # via
54
59
  # psycopg
55
60
  # sqlalchemy
56
- virtualenv==20.26.1
61
+ virtualenv==20.26.3
57
62
  # via pre-commit
@@ -6,15 +6,14 @@ from typing import Any, Dict, List, Optional, Tuple, Type, Union
6
6
  from sqlalchemy import Engine, TableClause, create_engine
7
7
  from sqlalchemy.exc import SQLAlchemyError
8
8
  from sqlalchemy.ext.automap import automap_base
9
- from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
9
+ from sqlalchemy.orm import DeclarativeBase, Session
10
10
  from sqlalchemy.pool import NullPool
11
11
  from sqlalchemy.sql.ddl import CreateSchema, DropSchema
12
- from sqlalchemy.util import Properties
13
12
 
14
13
  from ._version import version as __version__ # noqa: F401
15
14
  from .dburi import get_connection_uri
16
15
  from .no_timezone import Base as NoTZBase
17
- from .postgresql import wait_for_postgresql
16
+ from .postgresql import restore_from_pgfile, wait_for_postgresql
18
17
  from .views import view
19
18
  from .with_timezone import Base as TZBase
20
19
 
@@ -41,6 +40,9 @@ class Database:
41
40
  with timezones to timezoneless database columns (but not when using
42
41
  reflection). If table_base is supplied, db_has_tz is ignored.
43
42
 
43
+ The database can be restored from a given pg_restore file supplied in the
44
+ parameter pg_restore_file.
45
+
44
46
  There is an option to wipe and create an empty schema in the database by
45
47
  setting recreate_schema to True and setting a schema_name ("public" is the
46
48
  default).
@@ -63,6 +65,7 @@ class Database:
63
65
  table_base (Optional[Type[DeclarativeBase]]): Override table base. Defaults to None.
64
66
  reflect (bool): Whether to reflect existing tables. Defaults to False.
65
67
  **kwargs: See below
68
+ pg_restore_file (str): Restore database from pg_restore file
66
69
  recreate_schema (bool): Whether to recreate schema
67
70
  schema_name (str): Database schema name. Defaults to "public".
68
71
  prepare_fn (Callable[[], None]]): Function to call before Base.metadata.create_all.
@@ -95,9 +98,11 @@ class Database:
95
98
  port = int(port)
96
99
  schema_name = None
97
100
  if len(kwargs) == 0:
101
+ pg_restore_file = None
98
102
  recreate_schema = False
99
103
  prepare_fn = do_nothing_fn
100
104
  else:
105
+ pg_restore_file = kwargs.pop("pg_restore_file", None)
101
106
  recreate_schema = kwargs.pop("recreate_schema", False)
102
107
  schema_name = kwargs.pop("schema", "public")
103
108
  prepare_fn = kwargs.pop("prepare_fn", do_nothing_fn)
@@ -144,6 +149,8 @@ class Database:
144
149
  )
145
150
  if not engine and dialect == "postgresql":
146
151
  wait_for_postgresql(db_uri)
152
+ if pg_restore_file and db_uri:
153
+ restore_from_pgfile(db_uri, pg_restore_file)
147
154
  if not table_base:
148
155
  if db_has_tz:
149
156
  table_base = TZBase
@@ -155,19 +162,31 @@ class Database:
155
162
  if recreate_schema:
156
163
  self.recreate_schema(engine, schema_name)
157
164
  self.prepare_results = prepare_fn()
158
- self.session, self.reflected_classes = self.create_session(
165
+ self.session, self.base = self.create_session(
159
166
  engine,
160
167
  table_base=table_base,
161
168
  reflect=reflect,
162
169
  )
170
+ if reflect:
171
+ self.reflected_classes = self.base.classes
172
+ else:
173
+ self.reflected_classes = None
174
+
175
+ def cleanup(self) -> None:
176
+ self.base.metadata.clear()
177
+ self.session.close()
178
+ self.engine.dispose()
179
+ if self.server is not None:
180
+ self.server.stop()
163
181
 
164
182
  def __enter__(self) -> "Database":
165
183
  return self
166
184
 
167
185
  def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
168
- self.session.close()
169
- if self.server is not None:
170
- self.server.stop()
186
+ self.cleanup()
187
+
188
+ def drop_all(self):
189
+ self.base.metadata.drop_all(self.engine)
171
190
 
172
191
  def get_engine(self) -> Engine:
173
192
  """Returns SQLAlchemy engine.
@@ -207,7 +226,7 @@ class Database:
207
226
  db_uri: Optional[str] = None,
208
227
  table_base: Type[DeclarativeBase] = NoTZBase,
209
228
  reflect: bool = False,
210
- ) -> Tuple[Session, Optional[Properties]]:
229
+ ) -> Tuple[Session, Any]:
211
230
  """Creates SQLAlchemy session given SQLAlchemy engine or database uri
212
231
  (one of which must be supplied). Tables must inherit from Base in
213
232
  hdx.utilities.database unless base is defined. If reflect is True,
@@ -222,22 +241,19 @@ class Database:
222
241
  reflect (bool): Whether to reflect existing tables. Defaults to False.
223
242
 
224
243
  Returns:
225
- Tuple[Session, Optional[Properties]]: (SQLAlchemy session, reflected classes if available)
244
+ Tuple[Session, Any]: (SQLAlchemy session, base)
226
245
  """
227
246
  if not engine:
228
247
  if db_uri is None:
229
248
  raise DatabaseError("No engine or database uri provided!")
230
249
  engine = create_engine(db_uri, poolclass=NullPool, echo=False)
231
- Session = sessionmaker(bind=engine)
232
250
  if reflect:
233
251
  Base = automap_base(declarative_base=table_base)
234
252
  Base.prepare(autoload_with=engine)
235
- reflected_classes = Base.classes
253
+ table_base = Base
236
254
  else:
237
255
  table_base.metadata.create_all(engine)
238
- reflected_classes = None
239
- session = Session()
240
- return session, reflected_classes
256
+ return Session(engine), table_base
241
257
 
242
258
  @staticmethod
243
259
  def recreate_schema(engine: Engine, schema_name: str = "public") -> bool:
@@ -285,6 +301,7 @@ class Database:
285
301
 
286
302
  Args:
287
303
  view_params_list (List[Dict]): List of dictionaries with view parameters
304
+
288
305
  Returns:
289
306
  List[TableClause]: SQLAlchemy Views
290
307
  """
@@ -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.1'
16
- __version_tuple__ = version_tuple = (1, 3, 1)
15
+ __version__ = version = '1.3.2'
16
+ __version_tuple__ = version_tuple = (1, 3, 2)
@@ -0,0 +1,95 @@
1
+ """PostgreSQL specific utilities"""
2
+
3
+ import logging
4
+ import subprocess
5
+ import time
6
+ from os import environ
7
+
8
+ from .dburi import get_params_from_connection_uri, remove_driver_from_uri
9
+
10
+ try:
11
+ import psycopg
12
+ except ImportError:
13
+ psycopg = None
14
+ # dependency missing, issue a warning
15
+ import warnings
16
+
17
+ warnings.warn(
18
+ "psycopg not found! Please install hdx-python-database[postgresql] to enable."
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class PostgresError(Exception):
25
+ pass
26
+
27
+
28
+ def wait_for_postgresql(db_uri: str) -> None:
29
+ """Waits for PostgreSQL database to be up
30
+
31
+ Args:
32
+ db_uri (str): Connection URI
33
+
34
+ Returns:
35
+ None
36
+ """
37
+ connecting_string = "Checking for PostgreSQL..."
38
+ db_uri_nd = remove_driver_from_uri(db_uri)
39
+ while True:
40
+ try:
41
+ logger.info(connecting_string)
42
+ connection = psycopg.connect(db_uri_nd, connect_timeout=3)
43
+ connection.close()
44
+ logger.info("PostgreSQL is running!")
45
+ break
46
+ except psycopg.OperationalError:
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
@@ -1,3 +1,4 @@
1
+ import subprocess
1
2
  from collections import namedtuple
2
3
 
3
4
  import psycopg
@@ -25,7 +26,11 @@ def mock_psycopg(monkeypatch):
25
26
  PsycopgConnection.connected = True
26
27
  raise psycopg.OperationalError
27
28
 
28
- monkeypatch.setattr(psycopg, "connect", connect)
29
+ try:
30
+ monkeypatch.setattr(psycopg, "connect", connect)
31
+ yield
32
+ finally:
33
+ monkeypatch.delattr(psycopg, "connect")
29
34
 
30
35
 
31
36
  @pytest.fixture(scope="function")
@@ -39,12 +44,6 @@ def mock_SSHTunnelForwarder(monkeypatch):
39
44
  def stop(_):
40
45
  TestDatabase.stopped = True
41
46
 
42
- monkeypatch.setattr(SSHTunnelForwarder, "__init__", init)
43
- monkeypatch.setattr(SSHTunnelForwarder, "start", start)
44
- monkeypatch.setattr(SSHTunnelForwarder, "stop", stop)
45
- monkeypatch.setattr(SSHTunnelForwarder, "local_bind_host", "0.0.0.0")
46
- monkeypatch.setattr(SSHTunnelForwarder, "local_bind_port", 12345)
47
-
48
47
  def create_session(_, engine, table_base, reflect):
49
48
  class Session:
50
49
  bind = namedtuple("Bind", "engine")
@@ -53,10 +52,23 @@ def mock_SSHTunnelForwarder(monkeypatch):
53
52
  return None
54
53
 
55
54
  Session.bind.engine = engine
56
- TestDatabase.table_base = table_base
57
- return Session(), None
58
-
59
- monkeypatch.setattr(Database, "create_session", create_session)
55
+ return Session(), table_base
56
+
57
+ try:
58
+ monkeypatch.setattr(SSHTunnelForwarder, "__init__", init)
59
+ monkeypatch.setattr(SSHTunnelForwarder, "start", start)
60
+ monkeypatch.setattr(SSHTunnelForwarder, "stop", stop)
61
+ monkeypatch.setattr(SSHTunnelForwarder, "local_bind_host", "0.0.0.0")
62
+ monkeypatch.setattr(SSHTunnelForwarder, "local_bind_port", 12345)
63
+ monkeypatch.setattr(Database, "create_session", create_session)
64
+ yield
65
+ finally:
66
+ monkeypatch.delattr(SSHTunnelForwarder, "__init__")
67
+ monkeypatch.delattr(SSHTunnelForwarder, "start")
68
+ monkeypatch.delattr(SSHTunnelForwarder, "stop")
69
+ monkeypatch.delattr(SSHTunnelForwarder, "local_bind_host")
70
+ monkeypatch.delattr(SSHTunnelForwarder, "local_bind_port")
71
+ monkeypatch.delattr(Database, "create_session")
60
72
 
61
73
 
62
74
  @pytest.fixture(scope="function")
@@ -84,3 +96,22 @@ def mock_engine():
84
96
  return SQAConnection()
85
97
 
86
98
  return MockEngine()
99
+
100
+
101
+ @pytest.fixture(scope="function")
102
+ def mock_subprocess(monkeypatch):
103
+ class CompletedProcess:
104
+ stdout = "WORKED!"
105
+
106
+ @staticmethod
107
+ def check_returncode():
108
+ return
109
+
110
+ def run(*args, **kwargs):
111
+ return CompletedProcess()
112
+
113
+ try:
114
+ monkeypatch.setattr(subprocess, "run", run)
115
+ yield
116
+ finally:
117
+ monkeypatch.delattr(subprocess, "run")
@@ -0,0 +1,59 @@
1
+ """Database Utility Tests"""
2
+
3
+ from datetime import datetime, timezone
4
+ from os import remove
5
+ from os.path import join
6
+
7
+ import pytest
8
+ from sqlalchemy import select
9
+
10
+ from .dbtestdate import DBTestDate, date_view_params
11
+ from hdx.database import Database, DatabaseError
12
+ from hdx.database.no_timezone import Base as NoTZBase
13
+
14
+
15
+ class TestDatabase:
16
+ dbpath = join("tests", "test_database.db")
17
+
18
+ @pytest.fixture(scope="function")
19
+ def nodatabase(self):
20
+ try:
21
+ remove(TestDatabase.dbpath)
22
+ except OSError:
23
+ pass
24
+ return f"sqlite:///{TestDatabase.dbpath}"
25
+
26
+ def test_get_session(self, nodatabase):
27
+ assert DBTestDate.__tablename__ == "db_test_date"
28
+
29
+ def prepare_fn():
30
+ return Database.prepare_views([date_view_params])
31
+
32
+ with Database(
33
+ database=TestDatabase.dbpath,
34
+ port=None,
35
+ dialect="sqlite",
36
+ prepare_fn=prepare_fn,
37
+ ) as dbdatabase:
38
+ dbsession = dbdatabase.get_session()
39
+ assert str(dbsession.bind.engine.url) == nodatabase
40
+ assert dbdatabase.base == NoTZBase
41
+ now = datetime(2022, 10, 20, 22, 35, 55, tzinfo=timezone.utc)
42
+ input_dbtestdate = DBTestDate()
43
+ input_dbtestdate.test_date = now
44
+ dbsession.add(input_dbtestdate)
45
+ dbsession.commit()
46
+ dbtestdate = dbsession.execute(select(DBTestDate)).scalar_one()
47
+ assert dbtestdate.test_date == now
48
+ date_view = dbdatabase.get_prepare_results()[0]
49
+ dbtestdate = dbsession.execute(select(date_view)).scalar_one()
50
+ assert dbtestdate == now
51
+ dbdatabase.drop_all()
52
+
53
+ def test_errors(self):
54
+ with pytest.raises(DatabaseError):
55
+ Database.create_session()
56
+
57
+ with pytest.raises(DatabaseError):
58
+ with Database():
59
+ pass
@@ -0,0 +1,30 @@
1
+ """PostgreSQL Utility Tests"""
2
+
3
+ import pytest
4
+
5
+ from . import PsycopgConnection
6
+ from hdx.database.postgresql import (
7
+ PostgresError,
8
+ restore_from_pgfile,
9
+ wait_for_postgresql,
10
+ )
11
+
12
+
13
+ class TestPostgreSQL:
14
+ @pytest.fixture(scope="class")
15
+ def db_uri(self):
16
+ return "postgresql+psycopg://myuser:mypass@myserver:1234/mydatabase"
17
+
18
+ def test_wait_for_postgresql(self, db_uri, mock_psycopg):
19
+ PsycopgConnection.connected = False
20
+ wait_for_postgresql(db_uri)
21
+ assert PsycopgConnection.connected is True
22
+
23
+ def test_restore_from_pgfile(self, db_uri, mock_subprocess, mock_engine):
24
+ pg_restore_file = "test.pg_restore"
25
+ assert restore_from_pgfile(db_uri, pg_restore_file) == "WORKED!"
26
+
27
+ def test_restore_from_pgfile_raises(self, db_uri):
28
+ pg_restore_file = "NOT_EXIST.pg_restore"
29
+ with pytest.raises(PostgresError):
30
+ restore_from_pgfile(db_uri, pg_restore_file)
@@ -0,0 +1,8 @@
1
+ from hdx.database import Database
2
+
3
+
4
+ def test_recreate_schema(mock_engine):
5
+ db_uri = "postgresql+psycopg://myuser:mypass@0.0.0.0:12345/mydatabase"
6
+ assert Database.recreate_schema(mock_engine, db_uri) is True
7
+ db_uri = "Error"
8
+ assert Database.recreate_schema(mock_engine, db_uri) is False
@@ -0,0 +1,47 @@
1
+ """Database Utility Tests"""
2
+
3
+ from datetime import datetime
4
+ from os import remove
5
+ from os.path import join
6
+ from shutil import copyfile
7
+
8
+ import pytest
9
+ from sqlalchemy import select
10
+ from sqlalchemy.orm.decl_api import DeclarativeAttributeIntercept
11
+
12
+ from hdx.database import Database
13
+
14
+
15
+ class TestReflect:
16
+ dbpath = join("tests", "test_database.db")
17
+ testdb = join("tests", "fixtures", "test.db")
18
+
19
+ @pytest.fixture(scope="function")
20
+ def database_to_reflect(self):
21
+ try:
22
+ remove(TestReflect.dbpath)
23
+ copyfile(TestReflect.testdb, TestReflect.dbpath)
24
+ except OSError:
25
+ pass
26
+ return f"sqlite:///{TestReflect.dbpath}"
27
+
28
+ def test_get_reflect_session(self, database_to_reflect):
29
+ with Database(
30
+ database=TestReflect.dbpath,
31
+ port=None,
32
+ dialect="sqlite",
33
+ reflect=True,
34
+ ) as dbdatabase:
35
+ dbengine = dbdatabase.get_engine()
36
+ assert str(dbengine.url) == "sqlite:///tests/test_database.db"
37
+ dbsession = dbdatabase.get_session()
38
+ assert isinstance(dbdatabase.base, DeclarativeAttributeIntercept)
39
+ assert str(dbsession.bind.engine.url) == database_to_reflect
40
+ Table1 = dbdatabase.get_reflected_classes().table1
41
+ row = dbsession.execute(select(Table1)).scalar_one()
42
+ assert row.id == "1"
43
+ assert row.col1 == "wfrefds"
44
+ # with reflection, type annotation maps do not work and hence
45
+ # we don't have a timezone here
46
+ assert row.date1 == datetime(1993, 9, 23, 14, 12, 56, 111000)
47
+ dbdatabase.drop_all()
@@ -0,0 +1,80 @@
1
+ """Database Utility Tests"""
2
+
3
+ from copy import deepcopy
4
+
5
+ from hdx.database import Database
6
+ from hdx.database.no_timezone import Base as NoTZBase
7
+ from hdx.database.with_timezone import Base as TZBase
8
+
9
+
10
+ class TestSSH:
11
+ params_pg = {
12
+ "database": "mydatabase",
13
+ "host": "myserver",
14
+ "port": 1234,
15
+ "username": "myuser",
16
+ "password": "mypass",
17
+ "dialect": "postgresql",
18
+ "driver": "psycopg",
19
+ }
20
+
21
+ def test_get_session_ssh(
22
+ self,
23
+ mock_psycopg,
24
+ mock_SSHTunnelForwarder,
25
+ ):
26
+ params = deepcopy(self.params_pg)
27
+ del params["password"]
28
+ with Database(
29
+ ssh_host="mysshhost", ssh_port=25, db_has_tz=True, **params
30
+ ) as dbdatabase:
31
+ dbsession = dbdatabase.get_session()
32
+ assert (
33
+ str(dbsession.bind.engine.url)
34
+ == "postgresql+psycopg://myuser@0.0.0.0:12345/mydatabase"
35
+ )
36
+ assert dbdatabase.base == TZBase
37
+ try:
38
+ dbdatabase.drop_all()
39
+ except AttributeError:
40
+ pass
41
+
42
+ with Database(
43
+ ssh_host="mysshhost", ssh_port=25, table_base=TZBase, **params
44
+ ) as dbdatabase:
45
+ dbsession = dbdatabase.get_session()
46
+ assert (
47
+ str(dbsession.bind.engine.url)
48
+ == "postgresql+psycopg://myuser@0.0.0.0:12345/mydatabase"
49
+ )
50
+ assert dbdatabase.base == TZBase
51
+ try:
52
+ dbdatabase.drop_all()
53
+ except AttributeError:
54
+ pass
55
+
56
+ with Database(ssh_host="mysshhost", **self.params_pg) as dbdatabase:
57
+ dbsession = dbdatabase.get_session()
58
+ assert (
59
+ str(dbsession.bind.engine.url)
60
+ == "postgresql+psycopg://myuser:***@0.0.0.0:12345/mydatabase"
61
+ )
62
+ assert dbdatabase.base == NoTZBase
63
+ try:
64
+ dbdatabase.drop_all()
65
+ except AttributeError:
66
+ pass
67
+
68
+ with Database(
69
+ ssh_host="mysshhost", ssh_port=25, **params
70
+ ) as dbdatabase:
71
+ dbsession = dbdatabase.get_session()
72
+ assert (
73
+ str(dbsession.bind.engine.url)
74
+ == "postgresql+psycopg://myuser@0.0.0.0:12345/mydatabase"
75
+ )
76
+ assert dbdatabase.base == NoTZBase
77
+ try:
78
+ dbdatabase.drop_all()
79
+ except AttributeError:
80
+ pass
@@ -1,41 +0,0 @@
1
- """PostgreSQL specific utilities"""
2
-
3
- import logging
4
- import time
5
-
6
- from .dburi import remove_driver_from_uri
7
-
8
- try:
9
- import psycopg
10
- except ImportError:
11
- psycopg = None
12
- # dependency missing, issue a warning
13
- import warnings
14
-
15
- warnings.warn(
16
- "psycopg not found! Please install hdx-python-database[postgresql] to enable."
17
- )
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
- def wait_for_postgresql(db_uri: str) -> None:
23
- """Waits for PostgreSQL database to be up
24
-
25
- Args:
26
- db_uri (str): Connection URI
27
-
28
- Returns:
29
- None
30
- """
31
- connecting_string = "Checking for PostgreSQL..."
32
- db_uri_nd = remove_driver_from_uri(db_uri)
33
- while True:
34
- try:
35
- logger.info(connecting_string)
36
- connection = psycopg.connect(db_uri_nd, connect_timeout=3)
37
- connection.close()
38
- logger.info("PostgreSQL is running!")
39
- break
40
- except psycopg.OperationalError:
41
- time.sleep(1)
@@ -1,149 +0,0 @@
1
- """Database Utility Tests"""
2
-
3
- from copy import deepcopy
4
- from datetime import datetime, timezone
5
- from os import remove
6
- from os.path import join
7
- from shutil import copyfile
8
-
9
- import pytest
10
- from sqlalchemy import select
11
-
12
- from .dbtestdate import DBTestDate, date_view_params
13
- from hdx.database import Database, DatabaseError
14
- from hdx.database.no_timezone import Base as NoTZBase
15
- from hdx.database.with_timezone import Base as TZBase
16
-
17
-
18
- class TestDatabase:
19
- started = False
20
- stopped = False
21
- table_base = NoTZBase
22
- dbpath = join("tests", "test_database.db")
23
- testdb = join("tests", "fixtures", "test.db")
24
- params_pg = {
25
- "database": "mydatabase",
26
- "host": "myserver",
27
- "port": 1234,
28
- "username": "myuser",
29
- "password": "mypass",
30
- "dialect": "postgresql",
31
- "driver": "psycopg",
32
- }
33
-
34
- @pytest.fixture(scope="function")
35
- def nodatabase(self):
36
- try:
37
- remove(TestDatabase.dbpath)
38
- except OSError:
39
- pass
40
- return f"sqlite:///{TestDatabase.dbpath}"
41
-
42
- @pytest.fixture(scope="function")
43
- def database_to_reflect(self):
44
- try:
45
- remove(TestDatabase.dbpath)
46
- copyfile(TestDatabase.testdb, TestDatabase.dbpath)
47
- except OSError:
48
- pass
49
- return f"sqlite:///{TestDatabase.dbpath}"
50
-
51
- def test_get_session(self, nodatabase):
52
- assert DBTestDate.__tablename__ == "db_test_date"
53
-
54
- def prepare_fn():
55
- return Database.prepare_views([date_view_params])
56
-
57
- with Database(
58
- database=TestDatabase.dbpath,
59
- port=None,
60
- dialect="sqlite",
61
- prepare_fn=prepare_fn,
62
- ) as dbdatabase:
63
- dbsession = dbdatabase.session
64
- assert str(dbsession.bind.engine.url) == nodatabase
65
- assert TestDatabase.table_base == NoTZBase
66
- now = datetime(2022, 10, 20, 22, 35, 55, tzinfo=timezone.utc)
67
- input_dbtestdate = DBTestDate()
68
- input_dbtestdate.test_date = now
69
- dbsession.add(input_dbtestdate)
70
- dbsession.commit()
71
- dbtestdate = dbsession.execute(select(DBTestDate)).scalar_one()
72
- assert dbtestdate.test_date == now
73
- date_view = dbdatabase.get_prepare_results()[0]
74
- dbtestdate = dbsession.execute(select(date_view)).scalar_one()
75
- assert dbtestdate == now
76
- with pytest.raises(DatabaseError):
77
- Database.create_session()
78
- with pytest.raises(DatabaseError):
79
- with Database():
80
- pass
81
-
82
- def test_get_reflect_session(self, database_to_reflect):
83
- with Database(
84
- database=TestDatabase.dbpath,
85
- port=None,
86
- dialect="sqlite",
87
- reflect=True,
88
- ) as dbdatabase:
89
- dbengine = dbdatabase.get_engine()
90
- assert str(dbengine.url) == "sqlite:///tests/test_database.db"
91
- dbsession = dbdatabase.get_session()
92
- assert TestDatabase.table_base == NoTZBase
93
- assert str(dbsession.bind.engine.url) == database_to_reflect
94
- Table1 = dbdatabase.get_reflected_classes().table1
95
- row = dbsession.execute(select(Table1)).scalar_one()
96
- assert row.id == "1"
97
- assert row.col1 == "wfrefds"
98
- # with reflection, type annotation maps do not work and hence
99
- # we don't have a timezone here
100
- assert row.date1 == datetime(1993, 9, 23, 14, 12, 56, 111000)
101
-
102
- def test_get_session_ssh(
103
- self,
104
- mock_psycopg,
105
- mock_SSHTunnelForwarder,
106
- ):
107
- with Database(
108
- ssh_host="mysshhost", **TestDatabase.params_pg
109
- ) as dbdatabase:
110
- dbsession = dbdatabase.get_session()
111
- assert (
112
- str(dbsession.bind.engine.url)
113
- == "postgresql+psycopg://myuser:***@0.0.0.0:12345/mydatabase"
114
- )
115
- params = deepcopy(TestDatabase.params_pg)
116
- del params["password"]
117
- with Database(
118
- ssh_host="mysshhost", ssh_port=25, **params
119
- ) as dbdatabase:
120
- dbsession = dbdatabase.get_session()
121
- assert (
122
- str(dbsession.bind.engine.url)
123
- == "postgresql+psycopg://myuser@0.0.0.0:12345/mydatabase"
124
- )
125
- assert TestDatabase.table_base == NoTZBase
126
- with Database(
127
- ssh_host="mysshhost", ssh_port=25, db_has_tz=True, **params
128
- ) as dbdatabase:
129
- dbsession = dbdatabase.get_session()
130
- assert (
131
- str(dbsession.bind.engine.url)
132
- == "postgresql+psycopg://myuser@0.0.0.0:12345/mydatabase"
133
- )
134
- assert TestDatabase.table_base == TZBase
135
- with Database(
136
- ssh_host="mysshhost", ssh_port=25, table_base=TZBase, **params
137
- ) as dbdatabase:
138
- dbsession = dbdatabase.get_session()
139
- assert (
140
- str(dbsession.bind.engine.url)
141
- == "postgresql+psycopg://myuser@0.0.0.0:12345/mydatabase"
142
- )
143
- assert TestDatabase.table_base == TZBase
144
-
145
- def test_recreate_schema(self, mock_engine):
146
- db_uri = "postgresql+psycopg://myuser:mypass@0.0.0.0:12345/mydatabase"
147
- assert Database.recreate_schema(mock_engine, db_uri) is True
148
- db_uri = "Error"
149
- assert Database.recreate_schema(mock_engine, db_uri) is False
@@ -1,15 +0,0 @@
1
- """PostgreSQL Utility Tests"""
2
-
3
- from . import PsycopgConnection
4
- from hdx.database.postgresql import wait_for_postgresql
5
-
6
-
7
- class TestPostgreSQL:
8
- connection_uri_pg = (
9
- "postgresql+psycopg://myuser:mypass@myserver:1234/mydatabase"
10
- )
11
-
12
- def test_wait_for_postgresql(self, mock_psycopg):
13
- PsycopgConnection.connected = False
14
- wait_for_postgresql(TestPostgreSQL.connection_uri_pg)
15
- assert PsycopgConnection.connected is True