hdx-python-database 1.3.9__tar.gz → 1.4.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.
Files changed (36) hide show
  1. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/PKG-INFO +5 -4
  2. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/documentation/index.md +9 -3
  3. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/pyproject.toml +4 -4
  4. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/_version.py +2 -2
  5. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/database.py +9 -1
  6. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/dburi.py +5 -5
  7. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/no_timezone.py +4 -4
  8. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/postgresql.py +6 -2
  9. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/views.py +3 -2
  10. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/test_database.py +3 -3
  11. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/test_dburi.py +13 -0
  12. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/test_postgresql.py +12 -0
  13. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/test_ssh.py +9 -0
  14. hdx_python_database-1.4.0/tests/hdx/database/test_views.py +23 -0
  15. hdx_python_database-1.4.0/uv.lock +1505 -0
  16. hdx_python_database-1.3.9/uv.lock +0 -1513
  17. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/.coveragerc +0 -0
  18. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/.github/workflows/publish.yaml +0 -0
  19. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/.github/workflows/run-python-tests.yaml +0 -0
  20. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/.gitignore +0 -0
  21. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/.pre-commit-config.yaml +0 -0
  22. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/CLAUDE.md +0 -0
  23. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/CONTRIBUTING.md +0 -0
  24. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/LICENSE +0 -0
  25. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/README.md +0 -0
  26. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/documentation/.readthedocs.yaml +0 -0
  27. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/documentation/mkdocs.yaml +0 -0
  28. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/__init__.py +0 -0
  29. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/utils.py +0 -0
  30. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/src/hdx/database/with_timezone.py +0 -0
  31. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/fixtures/test.db +0 -0
  32. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/__init__.py +0 -0
  33. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/conftest.py +0 -0
  34. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/dbtestdate.py +0 -0
  35. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/test_drop_recreate.py +0 -0
  36. {hdx_python_database-1.3.9 → hdx_python_database-1.4.0}/tests/hdx/database/test_reflect.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hdx-python-database
3
- Version: 1.3.9
3
+ Version: 1.4.0
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@un.org>
@@ -17,14 +17,15 @@ Classifier: Operating System :: POSIX :: Linux
17
17
  Classifier: Operating System :: Unix
18
18
  Classifier: Programming Language :: Python
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
- Requires-Python: >=3.10
20
+ Requires-Python: >=3.11
21
21
  Requires-Dist: sqlalchemy>=2
22
22
  Provides-Extra: docs
23
23
  Requires-Dist: mkapi; extra == 'docs'
24
24
  Provides-Extra: postgresql
25
25
  Requires-Dist: psycopg[binary]; extra == 'postgresql'
26
26
  Provides-Extra: sshtunnel
27
- Requires-Dist: sshtunnel; extra == 'sshtunnel'
27
+ Requires-Dist: cryptography>=48.0.1; extra == 'sshtunnel'
28
+ Requires-Dist: sshtunnel>=0.5.1; extra == 'sshtunnel'
28
29
  Description-Content-Type: text/markdown
29
30
 
30
31
  [![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)
@@ -79,20 +79,26 @@ tunnel (which requires installing `hdx-python-database[sshtunnel]`):
79
79
 
80
80
  # Get SQLAlchemy session object given database parameters and
81
81
  # if needed SSH parameters. If database is PostgreSQL, will poll
82
- # till it is up.
82
+ # till it is up (or until pg_timeout seconds if supplied).
83
83
  from hdx.database import Database
84
84
  with Database(database="db", host="1.2.3.4", username="user",
85
85
  password="pass", dialect="dialect", driver="driver",
86
86
 
87
87
  ssh_host="5.6.7.8", ssh_port=2222, ssh_username="sshuser",
88
- ssh_private_key="path_to_key", db_has_tz=True,
89
- reflect=True, prepare_fn=prepare_views) as database:
88
+ ssh_private_key="path_to_key", ssh_host_key="AAAAC3...",
89
+ db_has_tz=True, reflect=True, prepare_fn=prepare_views,
90
+ pg_timeout=60) as database:
90
91
  engine = database.get_engine()
91
92
  session = database.get_session()
92
93
  reflected_classes = database.get_reflected_classes()
93
94
  views = database.get_prepare_results()
94
95
  session.query(...)
95
96
 
97
+ `ssh_host_key` is the SSH server's public host key, used to verify the server's
98
+ identity. Without it, the tunnel is open to man-in-the-middle attacks and a
99
+ warning is logged. It can be obtained with `ssh-keyscan -t ed25519 5.6.7.8`
100
+ (the base64 field of the output).
101
+
96
102
  `db_has_tz` which defaults to `False` indicates whether database datetime
97
103
  columns have timezones. If `db_has_tz` is `True`, use `Base` from
98
104
  `hdx.database.with_timezone`, otherwise use `Base` from
@@ -26,20 +26,20 @@ classifiers = [
26
26
  ]
27
27
  readme = "README.md"
28
28
  dynamic = ["version"]
29
- requires-python = ">=3.10"
29
+ requires-python = ">=3.11"
30
30
 
31
31
  dependencies = [
32
32
  "sqlalchemy>=2",
33
33
  ]
34
34
 
35
35
  [project.optional-dependencies]
36
- sshtunnel = ["sshtunnel"]
36
+ sshtunnel = ["sshtunnel>=0.5.1", "cryptography>=48.0.1"]
37
37
  postgresql = ["psycopg[binary]"]
38
38
  docs = ["mkapi"]
39
39
 
40
40
  [dependency-groups]
41
41
  dev = [
42
- "sshtunnel",
42
+ "sshtunnel>=0.5.1",
43
43
  "psycopg[binary]",
44
44
  "pytest",
45
45
  "pytest-cov",
@@ -75,7 +75,7 @@ allow-direct-references = true
75
75
  # ----------------------------------------------------------------------------
76
76
 
77
77
  [tool.ruff]
78
- target-version = "py310"
78
+ target-version = "py311"
79
79
  src = ["src"]
80
80
  exclude = ["_version.py"]
81
81
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.3.9'
22
- __version_tuple__ = version_tuple = (1, 3, 9)
21
+ __version__ = version = '1.4.0'
22
+ __version_tuple__ = version_tuple = (1, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -65,6 +65,7 @@ class Database:
65
65
  reflect (bool): Whether to reflect existing tables. Defaults to False.
66
66
  **kwargs: See below
67
67
  pg_restore_file (Path | str): Restore database from pg_restore file
68
+ pg_timeout (int): Seconds to wait for PostgreSQL. Defaults to None (wait forever).
68
69
  recreate_schema (bool): Whether to recreate schema
69
70
  schema_name (str): Database schema name. Defaults to "public".
70
71
  prepare_fn (Callable[[], None]]): Function to call before Base.metadata.create_all.
@@ -73,6 +74,7 @@ class Database:
73
74
  ssh_username (str): SSH username
74
75
  ssh_password (str): SSH password
75
76
  ssh_private_key: Location of SSH private key (instead of ssh_password)
77
+ ssh_host_key (str): SSH server host key to verify against. Strongly recommended.
76
78
  For more advanced usage, see SSHTunnelForwarder documentation.
77
79
 
78
80
  """
@@ -98,10 +100,12 @@ class Database:
98
100
  schema_name = None
99
101
  if len(kwargs) == 0:
100
102
  pg_restore_file = None
103
+ pg_timeout = None
101
104
  recreate_schema = False
102
105
  prepare_fn = do_nothing_fn
103
106
  else:
104
107
  pg_restore_file = kwargs.pop("pg_restore_file", None)
108
+ pg_timeout = kwargs.pop("pg_timeout", None)
105
109
  recreate_schema = kwargs.pop("recreate_schema", False)
106
110
  schema_name = kwargs.pop("schema", "public")
107
111
  prepare_fn = kwargs.pop("prepare_fn", do_nothing_fn)
@@ -116,6 +120,10 @@ class Database:
116
120
  raise
117
121
  ssh_host = kwargs["ssh_host"]
118
122
  del kwargs["ssh_host"]
123
+ if "ssh_host_key" not in kwargs:
124
+ logger.warning(
125
+ "ssh_host_key not supplied: SSH server identity will not be verified."
126
+ )
119
127
  ssh_port = kwargs.get("ssh_port")
120
128
  if ssh_port is not None:
121
129
  ssh_port = int(ssh_port)
@@ -147,7 +155,7 @@ class Database:
147
155
  "No engine, database uri or connection parameters provided!"
148
156
  )
149
157
  if not engine and dialect == "postgresql":
150
- wait_for_postgresql(db_uri)
158
+ wait_for_postgresql(db_uri, pg_timeout)
151
159
  if pg_restore_file and db_uri:
152
160
  restore_from_pgfile(db_uri, pg_restore_file)
153
161
  if not table_base:
@@ -1,7 +1,7 @@
1
1
  """Connection URI utilities"""
2
2
 
3
3
  from typing import Any
4
- from urllib.parse import urlsplit
4
+ from urllib.parse import quote_plus, unquote, urlsplit
5
5
 
6
6
 
7
7
  def get_params_from_connection_uri(
@@ -28,8 +28,8 @@ def get_params_from_connection_uri(
28
28
  "database": result.path[1:],
29
29
  "host": result.hostname,
30
30
  "port": result.port,
31
- "username": result.username,
32
- "password": result.password,
31
+ "username": unquote(result.username) if result.username else None,
32
+ "password": unquote(result.password) if result.password else None,
33
33
  "dialect": dialect,
34
34
  }
35
35
  if include_driver:
@@ -72,9 +72,9 @@ def get_connection_uri(
72
72
  strings.append(f"+{driver}")
73
73
  strings.append("://")
74
74
  if username:
75
- strings.append(username)
75
+ strings.append(quote_plus(username))
76
76
  if password:
77
- strings.append(f":{password}@")
77
+ strings.append(f":{quote_plus(password)}@")
78
78
  else:
79
79
  strings.append("@")
80
80
  if host:
@@ -1,6 +1,6 @@
1
1
  """Utilities for database datetime columns without timezone"""
2
2
 
3
- from datetime import datetime, timezone
3
+ from datetime import UTC, datetime
4
4
 
5
5
  from sqlalchemy import DateTime, TypeDecorator
6
6
  from sqlalchemy.orm import DeclarativeBase, declared_attr
@@ -19,7 +19,7 @@ class ConversionNoTZ(TypeDecorator):
19
19
  if value is None:
20
20
  return value
21
21
  if value.tzinfo:
22
- value = value.astimezone(timezone.utc)
22
+ value = value.astimezone(UTC)
23
23
 
24
24
  return value.replace(tzinfo=None)
25
25
 
@@ -27,9 +27,9 @@ class ConversionNoTZ(TypeDecorator):
27
27
  if value is None:
28
28
  return value
29
29
  if value.tzinfo is None:
30
- return value.replace(tzinfo=timezone.utc)
30
+ return value.replace(tzinfo=UTC)
31
31
 
32
- return value.astimezone(timezone.utc)
32
+ return value.astimezone(UTC)
33
33
 
34
34
 
35
35
  class Base(DeclarativeBase):
@@ -26,17 +26,19 @@ class PostgresError(Exception):
26
26
  pass
27
27
 
28
28
 
29
- def wait_for_postgresql(db_uri: str) -> None:
29
+ def wait_for_postgresql(db_uri: str, timeout: int | None = None) -> None:
30
30
  """Waits for PostgreSQL database to be up
31
31
 
32
32
  Args:
33
33
  db_uri (str): Connection URI
34
+ timeout (Optional[int]): Seconds to wait. Defaults to None (wait forever).
34
35
 
35
36
  Returns:
36
37
  None
37
38
  """
38
39
  connecting_string = "Checking for PostgreSQL..."
39
40
  db_uri_nd = remove_driver_from_uri(db_uri)
41
+ start = time.monotonic()
40
42
  while True:
41
43
  try:
42
44
  logger.info(connecting_string)
@@ -45,6 +47,8 @@ def wait_for_postgresql(db_uri: str) -> None:
45
47
  logger.info("PostgreSQL is running!")
46
48
  break
47
49
  except psycopg.OperationalError:
50
+ if timeout is not None and time.monotonic() - start > timeout:
51
+ raise PostgresError(f"PostgreSQL not available after {timeout}s")
48
52
  time.sleep(1)
49
53
 
50
54
 
@@ -59,7 +63,7 @@ def restore_from_pgfile(db_uri: str, pg_restore_file: Path | str) -> str:
59
63
  Returns:
60
64
  str: Output from the pg_restore command
61
65
  """
62
- pg_restore_file = Path(pg_restore_file)
66
+ pg_restore_file = Path(pg_restore_file).absolute()
63
67
  db_params = get_params_from_connection_uri(db_uri)
64
68
  if pg_restore_file.suffix == ".xz":
65
69
  decompress = subprocess.Popen(
@@ -24,12 +24,13 @@ class DropView(DDLElement):
24
24
 
25
25
  @compiler.compiles(CreateView)
26
26
  def _create_view(element, compiler, **kw):
27
- return f"CREATE VIEW {element.name} AS {compiler.sql_compiler.process(element.selectable, literal_binds=True)}"
27
+ name = compiler.preparer.quote(element.name)
28
+ return f"CREATE VIEW {name} AS {compiler.sql_compiler.process(element.selectable, literal_binds=True)}"
28
29
 
29
30
 
30
31
  @compiler.compiles(DropView)
31
32
  def _drop_view(element, compiler, **kw):
32
- return f"DROP VIEW {element.name}"
33
+ return f"DROP VIEW {compiler.preparer.quote(element.name)}"
33
34
 
34
35
 
35
36
  def view_exists(ddl, target, connection, **kw):
@@ -1,6 +1,6 @@
1
1
  """Database Utility Tests"""
2
2
 
3
- from datetime import datetime, timezone
3
+ from datetime import UTC, datetime
4
4
  from os import remove
5
5
  from os.path import exists, join
6
6
  from tempfile import gettempdir
@@ -32,7 +32,7 @@ class TestDatabase:
32
32
  dbsession = dbdatabase.get_session()
33
33
  assert str(dbsession.bind.engine.url) == f"sqlite:///{dbpath}"
34
34
  assert dbdatabase._base == NoTZBase
35
- now = datetime(2022, 10, 20, 22, 35, 55, tzinfo=timezone.utc)
35
+ now = datetime(2022, 10, 20, 22, 35, 55, tzinfo=UTC)
36
36
  input_dbtestdate = DBTestDate(test_date=now)
37
37
  dbsession.add(input_dbtestdate)
38
38
  dbsession.commit()
@@ -42,7 +42,7 @@ class TestDatabase:
42
42
  dbtestdate = dbsession.execute(select(date_view)).scalar_one()
43
43
  assert dbtestdate == now
44
44
 
45
- now = datetime(2023, 10, 20, 22, 35, 55, tzinfo=timezone.utc)
45
+ now = datetime(2023, 10, 20, 22, 35, 55, tzinfo=UTC)
46
46
  rows = [{"test_date": now}]
47
47
  dbdatabase.batch_populate(rows, DBTestDate)
48
48
  dbtestdate = dbsession.execute(select(DBTestDate)).all()[1][0].test_date
@@ -82,6 +82,19 @@ class TestDBURI:
82
82
  result = get_connection_uri(**TestDBURI.params_sq_driver_none)
83
83
  assert result == TestDBURI.connection_uri_sq
84
84
 
85
+ def test_special_characters(self):
86
+ params = {
87
+ **TestDBURI.params_pg,
88
+ "username": "my@user",
89
+ "password": "p@ss/w:rd#?",
90
+ }
91
+ result = get_connection_uri(**params)
92
+ assert (
93
+ result
94
+ == "postgresql+psycopg://my%40user:p%40ss%2Fw%3Ard%23%3F@myserver:1234/mydatabase"
95
+ )
96
+ assert get_params_from_connection_uri(result) == params
97
+
85
98
  def test_remove_driver_from_uri(self):
86
99
  db_uri_nd = remove_driver_from_uri(TestDBURI.connection_uri_pg)
87
100
  assert db_uri_nd == TestDBURI.connection_uri_pg_no_driver
@@ -1,5 +1,8 @@
1
1
  """PostgreSQL Utility Tests"""
2
2
 
3
+ import time
4
+
5
+ import psycopg
3
6
  import pytest
4
7
 
5
8
  from . import PsycopgConnection
@@ -20,6 +23,15 @@ class TestPostgreSQL:
20
23
  wait_for_postgresql(db_uri)
21
24
  assert PsycopgConnection.connected is True
22
25
 
26
+ def test_wait_for_postgresql_timeout(self, db_uri, monkeypatch):
27
+ def connect(*args, **kwargs):
28
+ raise psycopg.OperationalError
29
+
30
+ monkeypatch.setattr(psycopg, "connect", connect)
31
+ monkeypatch.setattr(time, "sleep", lambda _: None)
32
+ with pytest.raises(PostgresError):
33
+ wait_for_postgresql(db_uri, timeout=0)
34
+
23
35
  def test_restore_from_pgfile(self, db_uri, mock_subprocess, mock_engine):
24
36
  pg_restore_file = "test.pg_restore"
25
37
  assert restore_from_pgfile(db_uri, pg_restore_file) == "WORKED!"
@@ -20,6 +20,7 @@ class TestSSH:
20
20
 
21
21
  def test_get_session_ssh(
22
22
  self,
23
+ caplog,
23
24
  mock_psycopg,
24
25
  mock_SSHTunnelForwarder,
25
26
  ):
@@ -60,3 +61,11 @@ class TestSSH:
60
61
  == "postgresql+psycopg://myuser@0.0.0.0:12345/mydatabase"
61
62
  )
62
63
  assert dbdatabase._base == NoTZBase
64
+ assert "ssh_host_key not supplied" in caplog.text
65
+
66
+ caplog.clear()
67
+ with Database(
68
+ ssh_host="mysshhost", ssh_host_key="AAAAkey", **params
69
+ ) as dbdatabase:
70
+ assert dbdatabase._base == NoTZBase
71
+ assert "ssh_host_key not supplied" not in caplog.text
@@ -0,0 +1,23 @@
1
+ """View Tests"""
2
+
3
+ from sqlalchemy import column, select, table
4
+ from sqlalchemy.dialects import postgresql, sqlite
5
+
6
+ from hdx.database.views import CreateView, DropView
7
+
8
+
9
+ class TestViews:
10
+ selectable = select(table("t", column("a")))
11
+
12
+ def test_view_name_quoted(self):
13
+ for dialect in (sqlite.dialect(), postgresql.dialect()):
14
+ sql = str(CreateView("date_view", self.selectable).compile(dialect=dialect))
15
+ assert sql.startswith("CREATE VIEW date_view AS SELECT")
16
+ sql = str(DropView("date_view").compile(dialect=dialect))
17
+ assert sql == "DROP VIEW date_view"
18
+
19
+ name = 'x"; DROP TABLE t; --'
20
+ sql = str(CreateView(name, self.selectable).compile(dialect=dialect))
21
+ assert sql.startswith('CREATE VIEW "x""; DROP TABLE t; --" AS SELECT')
22
+ sql = str(DropView(name).compile(dialect=dialect))
23
+ assert sql == 'DROP VIEW "x""; DROP TABLE t; --"'