From 8382db4d25825c4d2637dfd68a468dfc4828ae35 Mon Sep 17 00:00:00 2001 From: trbtm Date: Tue, 16 Jul 2024 09:38:33 +0200 Subject: [PATCH 1/2] fix: fixed `should_reload` behaviour, close PostgreSQL connections, block until `PostgresqlWatcher` is ready, refactorings (#29) * chore: updated dev requirements * chore: format code with black * chore: updated .gitignore * fix: type hint, multiprocessing.Pipe is a Callable and not a type * fix: make Watcher.should_reload return value consistent * fix: Handle Connection and Process objects consistenly and close them before creating new ones * feat: Customize the postgres channel name * chore: Some code reorg - Make PostgresqlWatcher.create_subscription_process a private method - Rename casbin_subscription to _casbin_channel_subscription * docs: added doc string for PostgresqlWatcher.update * refactor: PostgresqlWatcher.set_update_callback * refactor!: Rename 'start_process' flag to 'start_listening' * docs: Added doc string to PostgresqlWatcher.__init__ * fix: Added proper destructor for PostgresqlWatcher * chore: fix type hints and proper handling of the channel_name argument and its default value * test: fix tests decrease select timeout to one second in child Process remove infinite timout in PostgresqlWatcher.should_reload create a new watcher instance for every test case * feat: Setup logging module for unit tests * fix: typo * feat: channel subscription with proper resource cleanup Moved channel subscription function to separate file and added context manager for the connection, that handles SIGINT, SIGTERM for proper resource cleanup * chore: removed unnecessary tests * feat: Wait for Process to be ready to receive messages from PostgreSQL * test: multiple instances of the watcher * test: make sure every test case uses its own channel * test: no update * refactor: moved code into with block * feat: automaticall call the update handler if it is provided * refactor: sorted imports * docs: updated README * refactor: improved readibility * refactor: resolve a potential infinite loop with a custom Exception * refactor: make timeout configurable by the user * fix: docs * fix: ensure type hint compatibility with Python 3.9 * feat: make sure multiple calls of update() get resolved by one call of should_reload() thanks to @pradeepranwa1 --- .gitignore | 1 + README.md | 44 +++- dev_requirements.txt | 2 +- postgresql_watcher/__init__.py | 2 +- .../casbin_channel_subscription.py | 108 +++++++++ postgresql_watcher/watcher.py | 210 +++++++++++------- setup.py | 6 +- tests/test_postgresql_watcher.py | 106 +++++++-- 8 files changed, 361 insertions(+), 118 deletions(-) create mode 100644 postgresql_watcher/casbin_channel_subscription.py diff --git a/.gitignore b/.gitignore index 8acb2f2..54431a9 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,4 @@ dmypy.json .idea/ *.iml +.vscode diff --git a/README.md b/README.md index a78cc00..33736e5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ pip install casbin-postgresql-watcher ``` ## Basic Usage Example -### With Flask-authz + ```python from flask_authz import CasbinEnforcer from postgresql_watcher import PostgresqlWatcher @@ -25,15 +25,22 @@ from casbin.persist.adapters import FileAdapter casbin_enforcer = CasbinEnforcer(app, adapter) watcher = PostgresqlWatcher(host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DBNAME) -watcher.set_update_callback(casbin_enforcer.e.load_policy) +watcher.set_update_callback(casbin_enforcer.load_policy) casbin_enforcer.set_watcher(watcher) -``` -## Basic Usage Example With SSL Enabled +# Call should_reload before every call of enforce to make sure +# the policy is update to date +watcher.should_reload() +if casbin_enforcer.enforce("alice", "data1", "read"): + # permit alice to read data1 + pass +else: + # deny the request, show an error + pass +``` -See [PostgresQL documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS) for full details of SSL parameters. +alternatively, if you need more control -### With Flask-authz ```python from flask_authz import CasbinEnforcer from postgresql_watcher import PostgresqlWatcher @@ -41,7 +48,28 @@ from flask import Flask from casbin.persist.adapters import FileAdapter casbin_enforcer = CasbinEnforcer(app, adapter) -watcher = PostgresqlWatcher(host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DBNAME, sslmode="verify_full", sslcert=SSLCERT, sslrootcert=SSLROOTCERT, sslkey=SSLKEY) -watcher.set_update_callback(casbin_enforcer.e.load_policy) +watcher = PostgresqlWatcher(host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DBNAME) casbin_enforcer.set_watcher(watcher) + +# Call should_reload before every call of enforce to make sure +# the policy is update to date +if watcher.should_reload(): + casbin_enforcer.load_policy() + +if casbin_enforcer.enforce("alice", "data1", "read"): + # permit alice to read data1 + pass +else: + # deny the request, show an error + pass +``` + +## Basic Usage Example With SSL Enabled + +See [PostgresQL documentation](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS) for full details of SSL parameters. + +```python +... +watcher = PostgresqlWatcher(host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DBNAME, sslmode="verify_full", sslcert=SSLCERT, sslrootcert=SSLROOTCERT, sslkey=SSLKEY) +... ``` diff --git a/dev_requirements.txt b/dev_requirements.txt index 8a3a4d0..b4f398b 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -1 +1 @@ -black==20.8b1 \ No newline at end of file +black==24.4.2 diff --git a/postgresql_watcher/__init__.py b/postgresql_watcher/__init__.py index 5f86668..c40d1dc 100644 --- a/postgresql_watcher/__init__.py +++ b/postgresql_watcher/__init__.py @@ -1 +1 @@ -from .watcher import PostgresqlWatcher +from .watcher import PostgresqlWatcher, PostgresqlWatcherChannelSubscriptionTimeoutError diff --git a/postgresql_watcher/casbin_channel_subscription.py b/postgresql_watcher/casbin_channel_subscription.py new file mode 100644 index 0000000..f568de8 --- /dev/null +++ b/postgresql_watcher/casbin_channel_subscription.py @@ -0,0 +1,108 @@ +from enum import IntEnum +from logging import Logger +from multiprocessing.connection import Connection +from select import select +from signal import signal, SIGINT, SIGTERM +from time import sleep +from typing import Optional + +from psycopg2 import connect, extensions, InterfaceError + + +CASBIN_CHANNEL_SELECT_TIMEOUT = 1 # seconds + + +def casbin_channel_subscription( + process_conn: Connection, + logger: Logger, + host: str, + user: str, + password: str, + channel_name: str, + port: int = 5432, + dbname: str = "postgres", + delay: int = 2, + sslmode: Optional[str] = None, + sslrootcert: Optional[str] = None, + sslcert: Optional[str] = None, + sslkey: Optional[str] = None, +): + # delay connecting to postgresql (postgresql connection failure) + sleep(delay) + db_connection = connect( + host=host, + port=port, + user=user, + password=password, + dbname=dbname, + sslmode=sslmode, + sslrootcert=sslrootcert, + sslcert=sslcert, + sslkey=sslkey, + ) + # Can only receive notifications when not in transaction, set this for easier usage + db_connection.set_isolation_level(extensions.ISOLATION_LEVEL_AUTOCOMMIT) + db_cursor = db_connection.cursor() + context_manager = _ConnectionManager(db_connection, db_cursor) + + with context_manager: + db_cursor.execute(f"LISTEN {channel_name};") + logger.debug("Waiting for casbin policy update") + process_conn.send(_ChannelSubscriptionMessage.IS_READY) + + while not db_cursor.closed: + try: + select_result = select( + [db_connection], + [], + [], + CASBIN_CHANNEL_SELECT_TIMEOUT, + ) + if select_result != ([], [], []): + logger.debug("Casbin policy update identified") + db_connection.poll() + while db_connection.notifies: + notify = db_connection.notifies.pop(0) + logger.debug(f"Notify: {notify.payload}") + process_conn.send(_ChannelSubscriptionMessage.RECEIVED_UPDATE) + except (InterfaceError, OSError) as e: + # Log an exception if these errors occurred without the context beeing closed + if not context_manager.connections_were_closed: + logger.critical(e, exc_info=True) + break + + +class _ChannelSubscriptionMessage(IntEnum): + IS_READY = 1 + RECEIVED_UPDATE = 2 + + +class _ConnectionManager: + """ + You can not use 'with' and a connection / cursor directly in this setup. + For more details see this issue: https://github.com/psycopg/psycopg2/issues/941#issuecomment-864025101. + As a workaround this connection manager / context manager class is used, that also handles SIGINT and SIGTERM and + closes the database connection. + """ + + def __init__(self, connection, cursor) -> None: + self.connection = connection + self.cursor = cursor + self.connections_were_closed = False + + def __enter__(self): + signal(SIGINT, self._close_connections) + signal(SIGTERM, self._close_connections) + return self + + def _close_connections(self, *_): + if self.cursor is not None: + self.cursor.close() + self.cursor = None + if self.connection is not None: + self.connection.close() + self.connection = None + self.connections_were_closed = True + + def __exit__(self, *_): + self._close_connections() diff --git a/postgresql_watcher/watcher.py b/postgresql_watcher/watcher.py index 95cd9b8..818b38a 100644 --- a/postgresql_watcher/watcher.py +++ b/postgresql_watcher/watcher.py @@ -1,73 +1,54 @@ -from typing import Optional, Callable -from psycopg2 import connect, extensions -from multiprocessing import Process, Pipe -import time -from select import select from logging import Logger, getLogger +from multiprocessing import Process, Pipe +from multiprocessing.connection import Connection +from time import sleep, time +from typing import Optional, Callable +from psycopg2 import connect, extensions -POSTGRESQL_CHANNEL_NAME = "casbin_role_watcher" +from .casbin_channel_subscription import ( + casbin_channel_subscription, + _ChannelSubscriptionMessage, +) -def casbin_subscription( - process_conn: Pipe, - logger: Logger, - host: str, - user: str, - password: str, - port: Optional[int] = 5432, - dbname: Optional[str] = "postgres", - delay: Optional[int] = 2, - channel_name: Optional[str] = POSTGRESQL_CHANNEL_NAME, - sslmode: Optional[str] = None, - sslrootcert: Optional[str] = None, - sslcert: Optional[str] = None, - sslkey: Optional[str] = None, -): - # delay connecting to postgresql (postgresql connection failure) - time.sleep(delay) - conn = connect( - host=host, - port=port, - user=user, - password=password, - dbname=dbname, - sslmode=sslmode, - sslrootcert=sslrootcert, - sslcert=sslcert, - sslkey=sslkey - ) - # Can only receive notifications when not in transaction, set this for easier usage - conn.set_isolation_level(extensions.ISOLATION_LEVEL_AUTOCOMMIT) - curs = conn.cursor() - curs.execute(f"LISTEN {channel_name};") - logger.debug("Waiting for casbin policy update") - while True and not curs.closed: - if not select([conn], [], [], 5) == ([], [], []): - logger.debug("Casbin policy update identified..") - conn.poll() - while conn.notifies: - notify = conn.notifies.pop(0) - logger.debug(f"Notify: {notify.payload}") - process_conn.send(notify.payload) +POSTGRESQL_CHANNEL_NAME = "casbin_role_watcher" class PostgresqlWatcher(object): + def __init__( self, host: str, user: str, password: str, - port: Optional[int] = 5432, - dbname: Optional[str] = "postgres", - channel_name: Optional[str] = POSTGRESQL_CHANNEL_NAME, - start_process: Optional[bool] = True, + port: int = 5432, + dbname: str = "postgres", + channel_name: Optional[str] = None, + start_listening: bool = True, sslmode: Optional[str] = None, sslrootcert: Optional[str] = None, sslcert: Optional[str] = None, sslkey: Optional[str] = None, logger: Optional[Logger] = None, - ): + ) -> None: + """ + Initialize a PostgresqlWatcher object. + + Args: + host (str): Hostname of the PostgreSQL server. + user (str): PostgreSQL username. + password (str): Password for the user. + port (int): Post of the PostgreSQL server. Defaults to 5432. + dbname (str): Database name. Defaults to "postgres". + channel_name (str): The name of the channel to listen to and to send updates to. When None a default is used. + start_listening (bool, optional): Flag whether to start listening to updates on the PostgreSQL channel. Defaults to True. + sslmode (Optional[str], optional): See `psycopg2.connect` for details. Defaults to None. + sslrootcert (Optional[str], optional): See `psycopg2.connect` for details. Defaults to None. + sslcert (Optional[str], optional): See `psycopg2.connect` for details. Defaults to None. + sslkey (Optional[str], optional): See `psycopg2.connect` for details. Defaults to None. + logger (Optional[Logger], optional): Custom logger to use. Defaults to None. + """ self.update_callback = None self.parent_conn = None self.host = host @@ -75,7 +56,9 @@ def __init__( self.user = user self.password = password self.dbname = dbname - self.channel_name = channel_name + self.channel_name = ( + channel_name if channel_name is not None else POSTGRESQL_CHANNEL_NAME + ) self.sslmode = sslmode self.sslrootcert = sslrootcert self.sslcert = sslcert @@ -83,28 +66,35 @@ def __init__( if logger is None: logger = getLogger() self.logger = logger - self.subscribed_process = self.create_subscriber_process(start_process) + self.parent_conn: Optional[Connection] = None + self.child_conn: Optional[Connection] = None + self.subscription_process: Optional[Process] = None + self._create_subscription_process(start_listening) + self.update_callback: Optional[Callable[[None], None]] = None - def create_subscriber_process( + def __del__(self) -> None: + self._cleanup_connections_and_processes() + + def _create_subscription_process( self, - start_process: Optional[bool] = True, + start_listening=True, delay: Optional[int] = 2, - ): - parent_conn, child_conn = Pipe() - if not self.parent_conn: - self.parent_conn = parent_conn - p = Process( - target=casbin_subscription, + ) -> None: + self._cleanup_connections_and_processes() + + self.parent_conn, self.child_conn = Pipe() + self.subscription_proces = Process( + target=casbin_channel_subscription, args=( - child_conn, + self.child_conn, self.logger, self.host, self.user, self.password, + self.channel_name, self.port, self.dbname, delay, - self.channel_name, self.sslmode, self.sslrootcert, self.sslcert, @@ -112,15 +102,52 @@ def create_subscriber_process( ), daemon=True, ) - if start_process: - p.start() - return p + if start_listening: + self.start() - def set_update_callback(self, fn_name: Callable): - self.logger.debug(f"runtime is set update callback {fn_name}") - self.update_callback = fn_name + def start( + self, + timeout=20, # seconds + ): + if not self.subscription_proces.is_alive(): + # Start listening to messages + self.subscription_proces.start() + # And wait for the Process to be ready to listen for updates + # from PostgreSQL + timeout_time = time() + timeout + while True: + if self.parent_conn.poll(): + message = int(self.parent_conn.recv()) + if message == _ChannelSubscriptionMessage.IS_READY: + break + if time() > timeout_time: + raise PostgresqlWatcherChannelSubscriptionTimeoutError(timeout) + sleep(1 / 1000) # wait for 1 ms - def update(self): + def _cleanup_connections_and_processes(self) -> None: + # Clean up potentially existing Connections and Processes + if self.parent_conn is not None: + self.parent_conn.close() + self.parent_conn = None + if self.child_conn is not None: + self.child_conn.close() + self.child_conn = None + if self.subscription_process is not None: + self.subscription_process.terminate() + self.subscription_process = None + + def set_update_callback(self, update_handler: Optional[Callable[[None], None]]): + """ + Set the handler called, when the Watcher detects an update. + Recommendation: `casbin_enforcer.adapter.load_policy` + """ + self.update_callback = update_handler + + def update(self) -> None: + """ + Called by `casbin.Enforcer` when an update to the model was made. + Informs other watchers via the PostgreSQL channel. + """ conn = connect( host=self.host, port=self.port, @@ -130,29 +157,42 @@ def update(self): sslmode=self.sslmode, sslrootcert=self.sslrootcert, sslcert=self.sslcert, - sslkey=self.sslkey + sslkey=self.sslkey, ) # Can only receive notifications when not in transaction, set this for easier usage conn.set_isolation_level(extensions.ISOLATION_LEVEL_AUTOCOMMIT) curs = conn.cursor() - curs.execute( - f"NOTIFY {self.channel_name},'casbin policy update at {time.time()}'" - ) + curs.execute(f"NOTIFY {self.channel_name},'casbin policy update at {time()}'") conn.close() - return True - def should_reload(self): + def should_reload(self) -> bool: try: - if self.parent_conn.poll(None): - message = self.parent_conn.recv() - self.logger.debug(f"message:{message}") - return True + should_reload_flag = False + while self.parent_conn.poll(): + message = int(self.parent_conn.recv()) + received_update = message == _ChannelSubscriptionMessage.RECEIVED_UPDATE + if received_update: + should_reload_flag = True + + if should_reload_flag and self.update_callback is not None: + self.update_callback() + + return should_reload_flag except EOFError: self.logger.warning( "Child casbin-watcher subscribe process has stopped, " "attempting to recreate the process in 10 seconds..." ) - self.subscribed_process, self.parent_conn = self.create_subscriber_process( - delay=10 - ) - return False + self._create_subscription_process(delay=10) + + return False + + +class PostgresqlWatcherChannelSubscriptionTimeoutError(RuntimeError): + """ + Raised if the channel subscription could not be established within a given timeout. + """ + + def __init__(self, timeout_in_seconds: float) -> None: + msg = f"The channel subscription could not be established within {timeout_in_seconds:.0f} seconds." + super().__init__(msg) diff --git a/setup.py b/setup.py index 03a55e3..fe4faff 100644 --- a/setup.py +++ b/setup.py @@ -14,10 +14,10 @@ long_description_content_type="text/markdown", url="https://github.com/pycasbin/postgresql-watcher", packages=find_packages(), - install_requires=open('requirements.txt').read().splitlines(), + install_requires=open("requirements.txt").read().splitlines(), extras_require={ - 'dev': [ - open('dev_requirements.txt').read().splitlines(), + "dev": [ + open("dev_requirements.txt").read().splitlines(), ] }, classifiers=[ diff --git a/tests/test_postgresql_watcher.py b/tests/test_postgresql_watcher.py index fa0356a..d3f9d70 100644 --- a/tests/test_postgresql_watcher.py +++ b/tests/test_postgresql_watcher.py @@ -1,9 +1,13 @@ +from logging import DEBUG, getLogger, StreamHandler +from multiprocessing import connection, context +from time import sleep +from unittest import TestCase, main +from unittest.mock import MagicMock import sys -import unittest -from multiprocessing.connection import Pipe from postgresql_watcher import PostgresqlWatcher -from multiprocessing import connection, context +from postgresql_watcher.casbin_channel_subscription import CASBIN_CHANNEL_SELECT_TIMEOUT + # Warning!!! , Please setup yourself config HOST = "127.0.0.1" @@ -12,43 +16,105 @@ PASSWORD = "123456" DBNAME = "postgres" +logger = getLogger() +logger.level = DEBUG +stream_handler = StreamHandler(sys.stdout) +logger.addHandler(stream_handler) -def get_watcher(): - return PostgresqlWatcher(host=HOST, port=PORT, user=USER, password=PASSWORD, dbname=DBNAME) +def get_watcher(channel_name): + return PostgresqlWatcher( + host=HOST, + port=PORT, + user=USER, + password=PASSWORD, + dbname=DBNAME, + logger=logger, + channel_name=channel_name, + ) -pg_watcher = get_watcher() try: import _winapi from _winapi import WAIT_OBJECT_0, WAIT_ABANDONED_0, WAIT_TIMEOUT, INFINITE -except ImportError: - if sys.platform == 'win32': - raise +except ImportError as e: + if sys.platform == "win32": + raise e _winapi = None -class TestConfig(unittest.TestCase): +class TestConfig(TestCase): def test_pg_watcher_init(self): + pg_watcher = get_watcher("test_pg_watcher_init") if _winapi: assert isinstance(pg_watcher.parent_conn, connection.PipeConnection) else: assert isinstance(pg_watcher.parent_conn, connection.Connection) - assert isinstance(pg_watcher.subscribed_process, context.Process) + assert isinstance(pg_watcher.subscription_proces, context.Process) + + def test_update_single_pg_watcher(self): + pg_watcher = get_watcher("test_update_single_pg_watcher") + pg_watcher.update() + sleep(CASBIN_CHANNEL_SELECT_TIMEOUT * 2) + self.assertTrue(pg_watcher.should_reload()) + + def test_no_update_single_pg_watcher(self): + pg_watcher = get_watcher("test_no_update_single_pg_watcher") + sleep(CASBIN_CHANNEL_SELECT_TIMEOUT * 2) + self.assertFalse(pg_watcher.should_reload()) + + def test_update_mutiple_pg_watcher(self): + channel_name = "test_update_mutiple_pg_watcher" + main_watcher = get_watcher(channel_name) + + other_watchers = [get_watcher(channel_name) for _ in range(5)] + main_watcher.update() + sleep(CASBIN_CHANNEL_SELECT_TIMEOUT * 2) + for watcher in other_watchers: + self.assertTrue(watcher.should_reload()) + + def test_no_update_mutiple_pg_watcher(self): + channel_name = "test_no_update_mutiple_pg_watcher" + main_watcher = get_watcher(channel_name) - def test_update_pg_watcher(self): - assert pg_watcher.update() is True + other_watchers = [get_watcher(channel_name) for _ in range(5)] + sleep(CASBIN_CHANNEL_SELECT_TIMEOUT * 2) + for watcher in other_watchers: + self.assertFalse(watcher.should_reload()) + self.assertFalse(main_watcher.should_reload()) - def test_default_update_callback(self): - assert pg_watcher.update_callback() is None + def test_update_handler_called(self): + channel_name = "test_update_handler_called" + main_watcher = get_watcher(channel_name) + handler = MagicMock() + main_watcher.set_update_callback(handler) + main_watcher.update() + sleep(CASBIN_CHANNEL_SELECT_TIMEOUT * 2) + self.assertTrue(main_watcher.should_reload()) + self.assertTrue(handler.call_count == 1) - def test_add_update_callback(self): - def _test_callback(): + def test_update_handler_called_multiple_channel_messages(self): + channel_name = "test_update_handler_called_multiple_channel_messages" + main_watcher = get_watcher(channel_name) + handler = MagicMock() + main_watcher.set_update_callback(handler) + number_of_updates = 5 + for _ in range(number_of_updates): + main_watcher.update() + sleep(CASBIN_CHANNEL_SELECT_TIMEOUT * (number_of_updates + 1)) + while main_watcher.should_reload(): pass + self.assertTrue(handler.call_count == 1) - pg_watcher.set_update_callback(_test_callback) - assert pg_watcher.update_callback == _test_callback + def test_update_handler_not_called(self): + channel_name = "test_update_handler_not_called" + main_watcher = get_watcher(channel_name) + handler = MagicMock() + main_watcher.set_update_callback(handler) + sleep(CASBIN_CHANNEL_SELECT_TIMEOUT * 2) + self.assertFalse(main_watcher.should_reload()) + self.assertTrue(handler.call_count == 0) if __name__ == "__main__": - unittest.main() + main() From 11466e0d63651d6198ecb2d3614e97702d4fcb52 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 16 Jul 2024 07:41:05 +0000 Subject: [PATCH 2/2] chore(release): 1.1.1 [skip ci] ## [1.1.1](https://github.com/pycasbin/postgresql-watcher/compare/v1.1.0...v1.1.1) (2024-07-16) ### Bug Fixes * fixed `should_reload` behaviour, close PostgreSQL connections, block until `PostgresqlWatcher` is ready, refactorings ([#29](https://github.com/pycasbin/postgresql-watcher/issues/29)) ([8382db4](https://github.com/pycasbin/postgresql-watcher/commit/8382db4d25825c4d2637dfd68a468dfc4828ae35)) --- CHANGELOG.md | 7 +++++++ setup.cfg | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325e69d..1e22f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Semantic Versioning Changelog +## [1.1.1](https://github.com/pycasbin/postgresql-watcher/compare/v1.1.0...v1.1.1) (2024-07-16) + + +### Bug Fixes + +* fixed `should_reload` behaviour, close PostgreSQL connections, block until `PostgresqlWatcher` is ready, refactorings ([#29](https://github.com/pycasbin/postgresql-watcher/issues/29)) ([8382db4](https://github.com/pycasbin/postgresql-watcher/commit/8382db4d25825c4d2637dfd68a468dfc4828ae35)) + # [1.1.0](https://github.com/pycasbin/postgresql-watcher/compare/v1.0.0...v1.1.0) (2024-07-03) diff --git a/setup.cfg b/setup.cfg index 2ceab74..b62b490 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,5 +3,5 @@ universal = 1 [metadata] description-file = README.md -version = 1.1.0 +version = 1.1.1