Skip to content

Commit 115769c

Browse files
authored
Hot fix (#37)
* adding env test * fix: support DATABASE_URL with special chars and optional async deps - Fix PGMQConfig URI parser to match libpq behavior (split on last @ in netloc) so passwords containing @, /, or + parse correctly - Use re-assembled DSNs in queue.py/async_queue.py instead of passing malformed raw URIs to drivers - Rewrite SQLAlchemy connection URLs to use explicit +psycopg/+asyncpg drivers so bare postgresql:// doesn't fall back to missing psycopg2 - Make async_queue import optional in __init__.py (ModuleNotFoundError when asyncpg is not installed) - Add python-dotenv to dev deps and load .env in tests/__init__.py - Update test utilities to respect DATABASE_URL and fall back to PG_* env vars for Supabase/remote testing * add .env.bck * fix close() * bump the version * update uv.lock * fix: improve connection string parsing for DATABASE_URL * fix: remove python-dotenv dependency from project * fix: update connection string handling to use async_dsn for SQLAlchemy engines
1 parent 843db9a commit 115769c

17 files changed

Lines changed: 323 additions & 705 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ celerybeat.pid
308308

309309
# Environments
310310
.env
311+
.env.bck
311312
.envrc
312313
.venv
313314
env/

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@ run-pgmq-postgres:
3030

3131
test: clear-postgres run-pgmq-postgres
3232
sleep 10 # Give PostgreSQL time to start
33+
uv run python -m unittest discover -s tests -p "test_*.py"
34+
35+
test-env:
3336
uv run python -m unittest discover -s tests -p "test_*.py"

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pgmq"
3-
version = "1.1.0"
3+
version = "1.1.1"
44
description = "Python client for the PGMQ Postgres extension."
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -25,7 +25,7 @@ classifiers = [
2525

2626
authors = [
2727
{ name = "Adam Hendel",email = "adam@hendel.dev" },
28-
{ name = "Ali Tavallaie", email = "ali@techbend.io" },
28+
{ name = "Ali Tavallaie", email = "ali@techbend.dev" },
2929
]
3030
dependencies = [
3131
"orjson>=3.11.3",
@@ -48,6 +48,7 @@ bench = [
4848
dev = [
4949
"loguru>=0.7.3",
5050
"pre-commit>=4.3.0",
51+
"python-dotenv>=1.2.2",
5152
"ruff>=0.12.12",
5253
]
5354
docs = [

src/pgmq/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,21 @@
1919

2020
# Client classes (default psycopg-based)
2121
from pgmq.queue import PGMQueue as SyncPGMQueue
22-
from pgmq.async_queue import PGMQueue as AsyncPGMQueue
22+
23+
try:
24+
from pgmq.async_queue import PGMQueue as AsyncPGMQueue
25+
except ImportError: # pragma: no cover
26+
AsyncPGMQueue = None # type: ignore[misc, assignment]
2327

2428
# SQLAlchemy-based clients (available when sqlalchemy is installed)
2529
try:
2630
from pgmq.sqlalchemy_queue import PGMQueue as SQLAlchemyPGMQueue
27-
from pgmq.sqlalchemy_async_queue import PGMQueue as SQLAlchemyAsyncPGMQueue
2831
except ImportError: # pragma: no cover
2932
SQLAlchemyPGMQueue = None # type: ignore[misc, assignment]
33+
34+
try:
35+
from pgmq.sqlalchemy_async_queue import PGMQueue as SQLAlchemyAsyncPGMQueue
36+
except ImportError: # pragma: no cover
3037
SQLAlchemyAsyncPGMQueue = None # type: ignore[misc, assignment]
3138

3239
# Decorators

src/pgmq/async_queue.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,13 +78,10 @@ def __post_init__(self) -> None:
7878
async def init(self) -> None:
7979
"""Initialize the asyncpg connection pool."""
8080
log_with_context(self.logger, logging.DEBUG, "Creating asyncpg pool")
81-
dsn = (
82-
self.config.conn_string
83-
if self.config.conn_string
84-
else self.config.async_dsn
85-
)
81+
# Always use the re-encoded URI so that malformed original URIs
82+
# (e.g. passwords containing unescaped @ or /) are corrected.
8683
self.pool = await asyncpg.create_pool(
87-
dsn,
84+
self.config.async_dsn,
8885
min_size=1,
8986
max_size=self.config.pool_size,
9087
)

src/pgmq/base.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,49 @@ def _parse_conn_string(self, conn_string: str) -> None:
7070
Supports:
7171
- URI: postgresql://user:pass@host:port/database
7272
- Libpq: host=localhost port=5432 dbname=database user=postgres password=postgres
73+
74+
For URIs, this uses a libpq-compatible parser that splits the netloc on
75+
the *last* ``@`` (so passwords containing ``@`` work) and tolerates
76+
unescaped ``/`` or ``+`` inside the password.
7377
"""
7478
# URI Format
7579
if "://" in conn_string:
7680
try:
81+
# Try standard urlparse first
7782
parsed = urllib.parse.urlparse(conn_string)
7883

79-
if parsed.hostname:
80-
self.host = parsed.hostname
81-
if parsed.port:
82-
self.port = str(parsed.port)
83-
if parsed.path and len(parsed.path) > 1:
84-
# path is usually '/dbname', slice off the leading '/'
85-
self.database = parsed.path[1:]
86-
if parsed.username:
87-
self.username = parsed.username
88-
if parsed.password:
89-
self.password = parsed.password
84+
# Mimic libpq: split on the LAST @ to handle passwords containing @
85+
scheme, rest = conn_string.split("://", 1)
86+
if "@" in rest:
87+
userinfo, hostpath = rest.rsplit("@", 1)
88+
if ":" in userinfo:
89+
self.username, self.password = userinfo.split(":", 1)
90+
else:
91+
self.username = userinfo
92+
93+
self.username = urllib.parse.unquote(self.username)
94+
if self.password:
95+
self.password = urllib.parse.unquote(self.password)
96+
else:
97+
hostpath = rest
98+
99+
# Parse hostpath (host:port/database?options)
100+
# Prepend a dummy scheme to use urlparse safely on the remaining part
101+
hp_parsed = urllib.parse.urlparse("http://" + hostpath)
102+
if hp_parsed.hostname:
103+
self.host = hp_parsed.hostname
104+
if hp_parsed.port:
105+
self.port = str(hp_parsed.port)
106+
if hp_parsed.path and len(hp_parsed.path) > 1:
107+
# path is '/dbname', slice off the leading '/'
108+
self.database = urllib.parse.unquote(hp_parsed.path[1:])
109+
110+
# If urlparse gave us userinfo that we didn't extract above,
111+
# prefer the rsplit result (handles passwords with @)
112+
if not self.username and parsed.username is not None:
113+
self.username = urllib.parse.unquote(parsed.username)
114+
if not self.password and parsed.password is not None:
115+
self.password = urllib.parse.unquote(parsed.password)
90116

91117
except Exception as e:
92118
raise ValueError(f"Failed to parse connection URI: {e}")

src/pgmq/queue.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,10 @@ def __post_init__(self):
7171
def _init_pool(self) -> None:
7272
"""Initialize the connection pool."""
7373
log_with_context(self.logger, logging.DEBUG, "Creating connection pool")
74-
dsn = self.config.conn_string if self.config.conn_string else self.config.dsn
74+
# Always use the re-assembled libpq DSN so that malformed URIs
75+
# (e.g. passwords containing unescaped @ or /) are corrected.
7576
self.pool = ConnectionPool(
76-
dsn,
77+
self.config.dsn,
7778
min_size=1,
7879
max_size=self.config.pool_size,
7980
open=True,

src/pgmq/sqlalchemy_async_queue.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from datetime import datetime
1212
import os
1313
import logging
14-
import urllib.parse
1514
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, async_sessionmaker
1615
from sqlalchemy.pool import AsyncAdaptedQueuePool
1716
from sqlalchemy import text
@@ -97,17 +96,11 @@ async def init(self) -> None:
9796
return
9897

9998
log_with_context(self.logger, logging.DEBUG, "Creating async SQLAlchemy engine")
100-
if self.config.conn_string:
101-
# If a full connection string is provided, use it directly
102-
connection_url = self.config.conn_string
103-
else:
104-
# Otherwise, construct it from individual components
105-
user = urllib.parse.quote_plus(self.config.username)
106-
password = urllib.parse.quote_plus(self.config.password)
107-
connection_url = (
108-
f"postgresql+asyncpg://{user}:{password}@"
109-
f"{self.config.host}:{self.config.port}/{self.config.database}"
110-
)
99+
# Use the re-assembled and quoted URI from config, swapping the driver prefix.
100+
# This handles both URI and libpq input formats and fixes malformed credentials.
101+
connection_url = self.config.async_dsn.replace(
102+
"postgresql://", "postgresql+asyncpg://", 1
103+
)
111104
self.engine = create_async_engine(
112105
connection_url,
113106
poolclass=AsyncAdaptedQueuePool,

src/pgmq/sqlalchemy_queue.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from datetime import datetime
1313
import os
1414
import logging
15-
import urllib.parse
1615
import warnings
1716
from sqlalchemy import create_engine, text, Engine
1817
from sqlalchemy.orm import sessionmaker, Session
@@ -92,17 +91,11 @@ def __post_init__(self):
9291
def _init_engine(self) -> None:
9392
"""Initialize the SQLAlchemy engine."""
9493
log_with_context(self.logger, logging.DEBUG, "Creating SQLAlchemy engine")
95-
if self.config.conn_string:
96-
# If a full connection string is provided, use it directly
97-
connection_url = self.config.conn_string
98-
else:
99-
# Otherwise, construct it from individual components
100-
user = urllib.parse.quote_plus(self.config.username)
101-
password = urllib.parse.quote_plus(self.config.password)
102-
connection_url = (
103-
f"postgresql+psycopg://{user}:{password}@"
104-
f"{self.config.host}:{self.config.port}/{self.config.database}"
105-
)
94+
# Use the re-assembled and quoted URI from config, swapping the driver prefix.
95+
# This handles both URI and libpq input formats and fixes malformed credentials.
96+
connection_url = self.config.async_dsn.replace(
97+
"postgresql://", "postgresql+psycopg://", 1
98+
)
10699
self.engine = create_engine(
107100
connection_url,
108101
poolclass=QueuePool,

tests/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Automatically load .env file so DATABASE_URL / PG_* variables are available
2+
# to PGMQConfig even when tests are run without `uv run --env-file`.
3+
from dotenv import load_dotenv
4+
5+
load_dotenv()

0 commit comments

Comments
 (0)