Skip to content

Commit 579d69e

Browse files
committed
use feedback in roles too, block cancellation if commit
1 parent 5c4d0fe commit 579d69e

File tree

4 files changed

+113
-6
lines changed

4 files changed

+113
-6
lines changed

pum/feedback.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Feedback(abc.ABC):
1616
def __init__(self) -> None:
1717
"""Initialize the feedback instance."""
1818
self._is_cancelled = False
19+
self._cancellation_locked = False
1920
self._current_step = 0
2021
self._total_steps = 0
2122

@@ -56,17 +57,32 @@ def is_cancelled(self) -> bool:
5657
5758
Returns:
5859
True if the operation should be cancelled, False otherwise.
60+
Always returns False if cancellation has been locked (after commit).
5961
"""
62+
if self._cancellation_locked:
63+
return False
6064
return self._is_cancelled
6165

6266
def cancel(self) -> None:
63-
"""Cancel the operation."""
64-
self._is_cancelled = True
67+
"""Cancel the operation.
68+
69+
Note: This will have no effect if cancellation has been locked (after commit).
70+
"""
71+
if not self._cancellation_locked:
72+
self._is_cancelled = True
6573

6674
def reset(self) -> None:
6775
"""Reset the cancellation status."""
6876
self._is_cancelled = False
6977

78+
def lock_cancellation(self) -> None:
79+
"""Lock cancellation to prevent it after a commit.
80+
81+
Once locked, is_cancelled() will always return False and cancel() will have no effect.
82+
This should be called immediately before committing database changes.
83+
"""
84+
self._cancellation_locked = True
85+
7086

7187
class LogFeedback(Feedback):
7288
"""Feedback implementation that logs progress messages.

pum/role_manager.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66

77
from .sql_content import SqlContent
8+
from .exceptions import PumException
89

910
if TYPE_CHECKING:
1011
from .feedback import Feedback
@@ -43,12 +44,14 @@ def grant(
4344
role: str,
4445
connection: psycopg.Connection,
4546
commit: bool = False,
47+
feedback: Optional["Feedback"] = None,
4648
) -> None:
4749
"""Grant the permission to the specified role.
4850
Args:
4951
role: The name of the role to grant the permission to.
5052
connection: The database connection to execute the SQL statements.
5153
commit: Whether to commit the transaction. Defaults to False.
54+
feedback: Optional feedback object for progress reporting.
5255
"""
5356
if not isinstance(role, str):
5457
raise TypeError("Role must be a string.")
@@ -57,6 +60,9 @@ def grant(
5760
raise ValueError("Schemas must be defined for the permission.")
5861

5962
for schema in self.schemas:
63+
if feedback and feedback.is_cancelled():
64+
raise PumException("Permission grant cancelled by user")
65+
6066
# Detect if schema exists; if not, warn and continue
6167
cursor = SqlContent("SELECT 1 FROM pg_namespace WHERE nspname = {schema}").execute(
6268
connection=connection,
@@ -121,6 +127,8 @@ def grant(
121127
raise ValueError(f"Unknown permission type: {self.type}")
122128

123129
if commit:
130+
if feedback:
131+
feedback.lock_cancellation()
124132
connection.commit()
125133

126134
def _grant_existing_types(
@@ -223,14 +231,24 @@ def exists(self, connection: psycopg.Connection) -> bool:
223231
return bool(cursor._pum_results)
224232

225233
def create(
226-
self, connection: psycopg.Connection, grant: bool = False, commit: bool = False
234+
self,
235+
connection: psycopg.Connection,
236+
grant: bool = False,
237+
commit: bool = False,
238+
feedback: Optional["Feedback"] = None,
227239
) -> None:
228240
"""Create the role in the database.
229241
Args:
230242
connection: The database connection to execute the SQL statements.
231243
grant: Whether to grant permissions to the role. Defaults to False.
232244
commit: Whether to commit the transaction. Defaults to False.
245+
feedback: Optional feedback object for progress reporting.
233246
"""
247+
if feedback and feedback.is_cancelled():
248+
from .exceptions import PumException
249+
250+
raise PumException("Role creation cancelled by user")
251+
234252
if self.exists(connection):
235253
logger.debug(f"Role {self.name} already exists, skipping creation.")
236254
else:
@@ -262,9 +280,13 @@ def create(
262280
)
263281
if grant:
264282
for permission in self.permissions():
265-
permission.grant(role=self.name, connection=connection, commit=commit)
283+
permission.grant(
284+
role=self.name, connection=connection, commit=False, feedback=feedback
285+
)
266286

267287
if commit:
288+
if feedback:
289+
feedback.lock_cancellation()
268290
connection.commit()
269291

270292

@@ -320,11 +342,17 @@ def create_roles(
320342
"""
321343
roles_list = list(self.roles.values())
322344
for role in roles_list:
345+
if feedback and feedback.is_cancelled():
346+
from .exceptions import PumException
347+
348+
raise PumException("Role creation cancelled by user")
323349
if feedback:
324350
feedback.increment_step()
325351
feedback.report_progress(f"Creating role: {role.name}")
326-
role.create(connection=connection, commit=False, grant=grant)
352+
role.create(connection=connection, commit=False, grant=grant, feedback=feedback)
327353
if commit:
354+
if feedback:
355+
feedback.lock_cancellation()
328356
connection.commit()
329357

330358
def grant_permissions(
@@ -341,11 +369,19 @@ def grant_permissions(
341369
"""
342370
roles_list = list(self.roles.values())
343371
for role in roles_list:
372+
if feedback and feedback.is_cancelled():
373+
from .exceptions import PumException
374+
375+
raise PumException("Permission grant cancelled by user")
344376
if feedback:
345377
feedback.increment_step()
346378
feedback.report_progress(f"Granting permissions to role: {role.name}")
347379
for permission in role.permissions():
348-
permission.grant(role=role.name, connection=connection, commit=False)
380+
permission.grant(
381+
role=role.name, connection=connection, commit=False, feedback=feedback
382+
)
349383
logger.info("All permissions granted to roles.")
350384
if commit:
385+
if feedback:
386+
feedback.lock_cancellation()
351387
connection.commit()

pum/upgrader.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ def install(
176176
)
177177

178178
if commit:
179+
feedback.lock_cancellation()
179180
feedback.report_progress("Committing changes...")
180181
connection.commit()
181182
logger.info("Changes committed to the database.")
@@ -380,6 +381,7 @@ def upgrade(
380381
connection=connection, commit=False, feedback=feedback
381382
)
382383

384+
feedback.lock_cancellation()
383385
feedback.report_progress("Committing changes...")
384386
connection.commit()
385387
logger.info("Upgrade completed and changes committed to the database.")
@@ -432,6 +434,8 @@ def uninstall(
432434
uninstall_hook.execute(connection=connection, commit=False, parameters=parameters)
433435

434436
if commit:
437+
feedback.lock_cancellation()
435438
feedback.report_progress("Committing changes...")
436439
connection.commit()
437440
logger.info("Uninstall completed and changes committed to the database.")
441+
logger.info("Uninstall completed and changes committed to the database.")

test/test_feedback.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,57 @@ def test_feedback_progression(self) -> None:
261261
self.assertTrue(any("create_second_table.sql" in msg for msg in feedback.messages))
262262
self.assertTrue(any("create_third_table.sql" in msg for msg in feedback.messages))
263263

264+
def test_cancellation_locked_after_commit(self) -> None:
265+
"""Test that cancellation is locked after commit and cannot be triggered."""
266+
feedback = LogFeedback()
267+
268+
# Initially, cancellation should work
269+
self.assertFalse(feedback.is_cancelled())
270+
feedback.cancel()
271+
self.assertTrue(feedback.is_cancelled())
272+
273+
# Reset for next test
274+
feedback.reset()
275+
self.assertFalse(feedback.is_cancelled())
276+
277+
# Lock cancellation (simulating what happens before commit)
278+
feedback.lock_cancellation()
279+
280+
# Try to cancel - should have no effect
281+
feedback.cancel()
282+
self.assertFalse(
283+
feedback.is_cancelled(), "Cancellation should be locked after lock_cancellation()"
284+
)
285+
286+
# is_cancelled should always return False when locked
287+
feedback._is_cancelled = True # Force internal flag
288+
self.assertFalse(feedback.is_cancelled(), "is_cancelled() should return False when locked")
289+
290+
def test_install_cancellation_not_possible_after_commit(self) -> None:
291+
"""Test that cancellation during install doesn't work after commit starts."""
292+
test_dir = Path("test") / "data" / "single_changelog"
293+
cfg = PumConfig(test_dir, pum={"module": "test_cancellation_lock"})
294+
295+
# Custom feedback that tries to cancel during "Committing changes"
296+
class CancelOnCommitFeedback(CustomFeedback):
297+
def report_progress(self, message: str, current: int = 0, total: int = 0) -> None:
298+
super().report_progress(message, current, total)
299+
if "Committing changes" in message:
300+
# Try to cancel during commit - should have no effect
301+
self.cancel()
302+
303+
feedback = CancelOnCommitFeedback()
304+
upgrader = Upgrader(cfg)
305+
306+
with psycopg.connect(f"service={self.pg_service}") as conn:
307+
# Should complete successfully despite cancel attempt during commit
308+
upgrader.install(connection=conn, feedback=feedback, commit=True)
309+
310+
# Verify cancellation was attempted but had no effect
311+
self.assertTrue(any("Committing changes" in msg for msg in feedback.messages))
312+
# Even though cancel() was called, is_cancelled() should return False
313+
self.assertFalse(feedback.is_cancelled())
314+
264315

265316
if __name__ == "__main__":
266317
unittest.main()

0 commit comments

Comments
 (0)