Python driver: Add skip_load parameter to skip LOAD 'age' statement#2366
Python driver: Add skip_load parameter to skip LOAD 'age' statement#2366uesleilima wants to merge 5 commits intoapache:masterfrom
skip_load parameter to skip LOAD 'age' statement#2366Conversation
…plugin loading. Fix for apache#2325
- Add 4 unit tests for setUpAge() skip_load behavior: skip_load=True skips LOAD, skip_load=False executes LOAD, load_from_plugins integration, and search_path always set. - Document skip_load in README under new "Managed PostgreSQL Usage" section for Azure/AWS RDS/etc. environments. - Fix syntax in existing load_from_plugins code example. Made-with: Cursor
tcolo
left a comment
There was a problem hiding this comment.
@uesleilima thanks a a lot for the PR - please addresse the issue sin the Claude Code Check.
Code Review
Summary
This PR addresses a real and valid problem — managed PostgreSQL environments (Azure, AWS RDS) where LOAD 'age' fails because the extension is pre-loaded via shared_preload_libraries. The implementation is backward-compatible and test coverage is solid.
However, there's a significant design conflict with the already-merged configure_connection() API that should be resolved before merging.
🔴 Critical
skip_load may leak into psycopg.connect() via **kwargs
(age/__init__.py)
The current module-level connect() signature is:
def connect(dsn=None, graph=None, connection_factory=None, cursor_factory=ClientCursor, load_from_plugins=False, **kwargs):
ag.connect(dsn=dsn, ..., load_from_plugins=load_from_plugins, **kwargs)
If skip_load is not explicitly named in this signature, it falls through **kwargs → Age.connect(**kwargs) → psycopg.connect(**kwargs), which will raise:
ProgrammingError: invalid dsn parameter 'skip_load'
skip_load must be extracted explicitly, the same way load_from_plugins is.
🟡 Suggestions
1. API duplication with configure_connection()
(age/age.py — setUpAge())
The already-merged configure_connection(load=False) (see commits 7d64707e, 2a8a7c2d) solves the exact same problem — skipping LOAD 'age' on managed services — with a safer opt-in default. This PR introduces a parallel API for the same use case.
2. Inverted semantics between the two APIs
| Function | Pattern | Safe default |
|---|---|---|
| configure_connection() | load=False (opt-in) | ✅ skips LOAD by default |
| setUpAge() (this PR) | skip_load=False (opt-out) |
A user reading both APIs won't know which to use for managed services, and the meaning of "safe default" is different for each. If both must coexist, the semantics should be aligned and the distinction documented clearly.
3. Undocumented interaction between skip_load=True and load_from_plugins=True
When both flags are set, LOAD is silently skipped — which is probably correct, but surprising. The docstring should document the precedence, or a ValueError should be raised for the contradictory combination.
4. Call chain not covered by tests
The unit tests mock setUpAge() in isolation, but the path age.connect(skip_load=True) → Age.connect() → setUpAge() isn't tested end-to-end. A test verifying the parameter is forwarded correctly through the full call chain would catch regressions like the **kwargs leak described above.
5. README should mention configure_connection() as an alternative
The new README section shows age.connect(skip_load=True) as the solution for managed environments. Since configure_connection() also exists and is better suited for pool-based setups, the docs should mention both and clarify the trade-offs.
✅ What Looks Good
- Fully backward-compatible (
skip_load=Falsedefault preserves existing behavior) - Search path and adapter registration are correctly preserved when
skip_load=True - Unit tests use mocking cleanly — no live DB required
- 5 test methods give solid coverage of
setUpAge()in isolation - The problem (
UndefinedFileon Azure PostgreSQL) is well-documented in the PR description
Verdict: Request Changes
The **kwargs leak is a likely runtime error that needs to be addressed. Beyond that, the team should align on whether skip_load on setUpAge() is needed given configure_connection(load=False) already exists, or document a clear distinction between the two. Merging both without resolving this will leave users with two confusingly similar solutions with opposite flag polarities.
- Raise ValueError when skip_load=True and load_from_plugins=True are both set (contradictory combination) - Add end-to-end test verifying skip_load is forwarded through the full age.connect() → Age.connect() → setUpAge() call chain - Replace fragile string assertions with assert_called_with/assert_any_call - README: mention configure_connection() as the pool-based alternative for managed PostgreSQL environments Made-with: Cursor
There was a problem hiding this comment.
Pull request overview
Adds an opt-out for executing LOAD 'age' during Python driver connection/setup, enabling use on managed PostgreSQL services (e.g., Azure) where AGE is preloaded via shared_preload_libraries and LOAD fails.
Changes:
- Add
skip_loadparameter tosetUpAge(),Age.connect(), and module-levelage.connect()to bypassLOADwhile keeping the rest of setup. - Add unit tests validating
skip_loadbehavior and argument forwarding. - Update Python driver README with managed-Postgres usage guidance.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| drivers/python/age/age.py | Introduces skip_load behavior in setUpAge() and forwards it through Age.connect(). |
| drivers/python/age/init.py | Exposes skip_load via module-level connect(). |
| drivers/python/test_age_py.py | Adds unit tests for skip_load behavior without requiring a DB connection. |
| drivers/python/README.md | Documents skip_load for managed PostgreSQL environments. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
configure_connection() is not part of this PR; use the available setUpAge() API with skip_load=True for the connection pool example. Made-with: Cursor
|
All review feedback has been addressed: tcolo's review:
Copilot review:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
drivers/python/age/age.py
Outdated
| else: | ||
| cursor.execute("LOAD 'age';") | ||
|
|
||
| cursor.execute("SET search_path = ag_catalog, '$user', public;") |
There was a problem hiding this comment.
SET search_path uses '$user' (single quotes), which PostgreSQL treats as a literal schema name rather than the special $user placeholder. This likely prevents the current user's schema from being included in search_path and diverges from the documented setup elsewhere in the repo (which uses "$user"). Consider changing this statement to use "$user" so it behaves as intended, and update tests accordingly.
| cursor.execute("SET search_path = ag_catalog, '$user', public;") | |
| cursor.execute('SET search_path = ag_catalog, "$user", public;') |
There was a problem hiding this comment.
Addressed in 56d3df3: SET search_path now uses "$user" so PostgreSQL expands the session user schema. Thanks for catching this.
| def test_skip_load_true_does_not_execute_load(self): | ||
| """When skip_load=True, LOAD 'age' must not be executed.""" | ||
| mock_conn, mock_cursor, mock_type_info = self._make_mock_conn() | ||
| with unittest.mock.patch("age.age.TypeInfo.fetch", return_value=mock_type_info), \ | ||
| unittest.mock.patch("age.age.checkGraphCreated"): | ||
| age.age.setUpAge(mock_conn, "test_graph", skip_load=True) | ||
| mock_cursor.execute.assert_called_once_with( | ||
| "SET search_path = ag_catalog, '$user', public;" | ||
| ) |
There was a problem hiding this comment.
These new tests assert the driver sets SET search_path = ag_catalog, '$user', public;, but PostgreSQL's special $user placeholder requires double quotes ("$user") to be expanded. As written, the tests lock in the likely-buggy single-quoted form; if the driver is corrected to "$user", update these assertions to match.
There was a problem hiding this comment.
Updated test expectations to match the corrected "$user" form (same commit as the driver fix).
| class TestSetUpAge(unittest.TestCase): | ||
| """Unit tests for setUpAge() skip_load parameter — no DB required.""" | ||
|
|
||
| def _make_mock_conn(self): | ||
| mock_conn = unittest.mock.MagicMock() | ||
| mock_cursor = unittest.mock.MagicMock() | ||
| mock_conn.cursor.return_value.__enter__ = unittest.mock.Mock(return_value=mock_cursor) | ||
| mock_conn.cursor.return_value.__exit__ = unittest.mock.Mock(return_value=False) | ||
| mock_conn.adapters = unittest.mock.MagicMock() |
There was a problem hiding this comment.
The CI workflow runs python test_age_py.py ..., which executes only the custom TestSuite built under if __name__ == '__main__' and does not include TestSetUpAge. As a result, these unit tests for skip_load won’t run in CI. Consider adding TestSetUpAge to the scripted suite, or switching the script to unittest.main() (and passing through args) so discovery runs all tests.
There was a problem hiding this comment.
TestSetUpAge is now included in the __main__ suite via TestLoader().loadTestsFromTestCase(TestSetUpAge), so CI’s python test_age_py.py … run executes the skip_load tests.
|
@uesleilima Please see above Copilot comments |
…Age in CI PostgreSQL treats single-quoted '$user' as a literal schema name; use "$user" so the session user schema is included. Include TestSetUpAge in the test_age_py __main__ suite so skip_load tests run in GitHub Actions. Made-with: Cursor
Fixes #2353
Problem
setUpAge()andAge.connect()unconditionally executeLOAD 'age'(orLOAD '$libdir/plugins/age'). On managed PostgreSQL services like Azure Database for PostgreSQL, the AGE extension is loaded server-side viashared_preload_librariesand the binary is not accessible at the file path expected byLOAD, causing apsycopg.errors.UndefinedFileerror. There is no way to use the driver in these environments without bypassing the setup entirely.Solution
Add a
skip_loadparameter (defaultFalse) tosetUpAge(),Age.connect(), and the module-levelage.connect(). WhenTrue, theLOADstatement is skipped while all other setup is preserved:SET search_pathThis is fully backward-compatible — existing code is unaffected.
Usage