Repository: rucio/rucio

Issue:
Plugin module for Auto Approval
### Description

At the moment rules eligible for auto approval need to satisfy the following conditions like `len(rses) == 1`, `len(bytes)<'value_set_in_rse_attribute'>`. 

However, we would like to make it configurable, allowing us to automate certain approvals in our workflow that cannot be defined using `number_of_files` and `bytes`. 

### Motivation

We are experimenting with an option to allow rule creation to users that follow certain criteria, like pre-defined `rse_expressions` and their `global usage`, to users, without messing with their current local quota allocations.  


### Change

If we all find it suitable. We envision a module of the likes of permissions. 
https://github.com/rucio/rucio/blob/ea4d2c7e2702b85a86a8668b2d852971f42fceb4/lib/rucio/core/rule.py#L255

This will be followed by the plugin invocation. 


FYI @drkovalskyi @dciangot 


Discussion:
Hi Rahul,

I think this makes a lot of sense. We should maybe think though of a good "boiler-plate" way to have these plugin-hooks imported from the policy, and executed throughout the code. For the permission/policy this is quite clear, because everything comes from `rucio.core.permission.has_persmission`, but we should do it for other things as well.

For the lfn2pfn algorithm we have 
https://github.com/rucio/rucio/blob/215524297f3d13369edda948d2622bb3e1412097/lib/rucio/common/utils.py#L1757
Which we might want to use for this as well.

Maybe @jamesp-epcc wants to comment here too. We should also document this way of adding policy hooks then better.
I'm not very familiar with the rules part of the code, so I'm not sure exactly how it should be integrated, but I would recommend creating a new class of algorithm that can be registered from policy packages, similar to the lfn2pfn, SURL and scope extraction classes that already exist. I will add some documentation on how to do this.

Resolving patch:
diff --git a/lib/rucio/core/plugins.py b/lib/rucio/core/plugins.py
new file mode 100644
--- /dev/null
+++ b/lib/rucio/core/plugins.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+# Copyright European Organization for Nuclear Research (CERN) since 2012
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import importlib
+import os
+from configparser import NoOptionError, NoSectionError
+from typing import Any, Callable, Dict, TypeVar, Type
+
+from rucio.common import config
+from rucio.common.exception import InvalidAlgorithmName
+from rucio.common.utils import check_policy_package_version
+from rucio.core.vo import list_vos
+
+
+PolicyPackageAlgorithmsT = TypeVar('PolicyPackageAlgorithmsT', bound='PolicyPackageAlgorithms')
+
+
+class PolicyPackageAlgorithms():
+    """
+    Base class for Rucio Policy Package Algorithms
+
+    ALGORITHMS is of type Dict[str, Dict[str. Callable[..., Any]]]
+    where the key is the algorithm type and the value is a dictionary of algorithm names and their callables
+    """
+    _ALGORITHMS: Dict[str, Dict[str, Callable[..., Any]]] = {}
+    _loaded_policy_modules = False
+
+    def __init__(self) -> None:
+        if not self._loaded_policy_modules:
+            self._register_all_policy_package_algorithms()
+            self._loaded_policy_modules = True
+
+    @classmethod
+    def _get_one_algorithm(cls: Type[PolicyPackageAlgorithmsT], algorithm_type: str, name: str) -> Callable[..., Any]:
+        """
+        Get the algorithm from the dictionary of algorithms
+        """
+        return cls._ALGORITHMS[algorithm_type][name]
+
+    @classmethod
+    def _get_algorithms(cls: Type[PolicyPackageAlgorithmsT], algorithm_type: str) -> Dict[str, Callable[..., Any]]:
+        """
+        Get the dictionary of algorithms for a given type
+        """
+        return cls._ALGORITHMS[algorithm_type]
+
+    @classmethod
+    def _register(
+            cls: Type[PolicyPackageAlgorithmsT],
+            algorithm_type: str, algorithm_dict: Dict[str, Callable[..., Any]]) -> None:
+        """
+        Provided a dictionary of callable function,
+        and the associated algorithm type,
+        register it as one of the valid algorithms.
+        """
+        if algorithm_type in cls._ALGORITHMS:
+            cls._ALGORITHMS[algorithm_type].update(algorithm_dict)
+        else:
+            cls._ALGORITHMS[algorithm_type] = algorithm_dict
+
+    @classmethod
+    def _supports(cls: Type[PolicyPackageAlgorithmsT], algorithm_type: str, name: str) -> bool:
+        """
+        Check if a algorithm is supported by the plugin
+        """
+        return name in cls._ALGORITHMS.get(algorithm_type, {})
+
+    @classmethod
+    def _register_all_policy_package_algorithms(cls: Type[PolicyPackageAlgorithmsT]) -> None:
+        '''
+        Loads all the algorithms of a given type from the policy package(s) and registers them
+        :param algorithm_type: the type of algorithm to register (e.g. 'surl', 'lfn2pfn')
+        :param dictionary: the dictionary to register them in
+        :param vo: the name of the relevant VO (None for single VO)
+        '''
+        try:
+            multivo = config.config_get_bool('common', 'multi_vo')
+        except (NoOptionError, NoSectionError):
+            multivo = False
+        if not multivo:
+            # single policy package
+            cls._try_importing_policy()
+        else:
+            # determine whether on client or server
+            client = False
+            if 'RUCIO_CLIENT_MODE' not in os.environ:
+                if not config.config_has_section('database') and config.config_has_section('client'):
+                    client = True
+            else:
+                if os.environ['RUCIO_CLIENT_MODE']:
+                    client = True
+
+            # on client, only register algorithms for selected VO
+            if client:
+                if 'RUCIO_VO' in os.environ:
+                    vo = os.environ['RUCIO_VO']
+                else:
+                    try:
+                        vo = str(config.config_get('client', 'vo'))
+                    except (NoOptionError, NoSectionError):
+                        vo = 'def'
+                cls._try_importing_policy(vo)
+            # on server, list all VOs and register their algorithms
+            else:
+                # policy package per VO
+                vos = list_vos()
+                for vo in vos:
+                    cls._try_importing_policy(vo['vo'])
+
+    @classmethod
+    def _try_importing_policy(cls: Type[PolicyPackageAlgorithmsT], vo: str = "") -> None:
+        try:
+            env_name = 'RUCIO_POLICY_PACKAGE' + ('' if not vo else '_' + vo.upper())
+            package = getattr(os.environ, env_name, "")
+            if not package:
+                package = str(config.config_get('policy', 'package' + ('' if not vo else '-' + vo)))
+
+            check_policy_package_version(package)
+            module = importlib.import_module(package)
+
+            if hasattr(module, 'get_algorithms'):
+                all_algorithms = module.get_algorithms()
+
+                # check that the names are correctly prefixed for multi-VO
+                if vo:
+                    for _, algorithms in all_algorithms.items():
+                        for k in algorithms.keys():
+                            if not k.lower().startswith(vo.lower()):
+                                raise InvalidAlgorithmName(k, vo)
+
+                # Updates the dictionary with the algorithms from the policy package
+                for algorithm_type, algorithm_dict in all_algorithms.items():
+                    cls._register(algorithm_type, algorithm_dict)
+
+        except (NoOptionError, NoSectionError, ImportError):
+            pass
diff --git a/lib/rucio/core/rule.py b/lib/rucio/core/rule.py
--- a/lib/rucio/core/rule.py
+++ b/lib/rucio/core/rule.py
@@ -15,19 +15,18 @@
 
 import json
 import logging
-from configparser import NoOptionError
+from configparser import NoOptionError, NoSectionError
 from copy import deepcopy
 from datetime import datetime, timedelta
 from os import path
 from re import match
 from string import Template
-from typing import Any, Optional
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any, Callable, Optional, Type, TypeVar
 
 from dogpile.cache.api import NO_VALUE
 from sqlalchemy import select, update
 from sqlalchemy.exc import IntegrityError, StatementError
-from sqlalchemy.orm.exc import NoResultFound
+from sqlalchemy.exc import NoResultFound  # https://pydoc.dev/sqlalchemy/latest/sqlalchemy.exc.NoResultFound.html
 from sqlalchemy.sql import func
 from sqlalchemy.sql.expression import and_, or_, true, null, tuple_, false
 
@@ -53,6 +52,7 @@
 from rucio.core.lifetime_exception import define_eol
 from rucio.core.message import add_message
 from rucio.core.monitor import MetricManager
+from rucio.core.plugins import PolicyPackageAlgorithms
 from rucio.core.rse import get_rse_name, list_rse_attributes, get_rse, get_rse_usage
 from rucio.core.rse_expression_parser import parse_expression
 from rucio.core.rse_selector import RSESelector
@@ -69,6 +69,76 @@
 
 REGION = make_region_memcached(expiration_time=900)
 METRICS = MetricManager(module=__name__)
+AutoApproveT = TypeVar('AutoApproveT', bound='AutoApprove')
+
+
+class AutoApprove(PolicyPackageAlgorithms):
+    """
+    Handle automatic approval algorithms for replication rules
+    """
+
+    _algorithm_type = 'auto_approve'
+
+    def __init__(self, rule: models.ReplicationRule, did: models.DataIdentifier, session: 'Session') -> None:
+        super().__init__()
+        self.rule = rule
+        self.did = did
+        self.session = session
+        self.register("default", self.default)
+
+    def evaluate(self) -> bool:
+        """
+        Evaluate the auto-approve algorithm
+        """
+        return self.get_configured_algorithm()(self.rule, self.did, self.session)
+
+    @classmethod
+    def get_configured_algorithm(cls: Type[AutoApproveT]) -> Callable[[models.ReplicationRule, models.DataIdentifier, 'Session'], bool]:
+        """
+        Get the configured auto-approve algorithm
+        """
+        try:
+            configured_algorithm: str = str(config_get('rules', cls._algorithm_type, default='default'))
+        except (NoOptionError, NoSectionError, RuntimeError):
+            configured_algorithm = 'default'
+
+        return super()._get_one_algorithm(cls._algorithm_type, configured_algorithm)
+
+    @classmethod
+    def register(cls: Type[AutoApproveT], name: str, fn_auto_approve: Callable[[models.ReplicationRule, models.DataIdentifier, 'Session'], bool]) -> None:
+        """
+        Register a new auto-approve algorithm
+        """
+        algorithm_dict = {name: fn_auto_approve}
+        super()._register(cls._algorithm_type, algorithm_dict)
+
+    @staticmethod
+    def default(rule: models.ReplicationRule, did: models.DataIdentifier, session: 'Session') -> bool:
+        """
+        Default auto-approve algorithm
+        """
+        rse_expression = rule['rse_expression']
+        vo = rule['account'].vo
+
+        rses = parse_expression(rse_expression, filter_={'vo': vo}, session=session)
+
+        auto_approve = False
+        # Block manual approval for multi-rse rules
+        if len(rses) > 1:
+            raise InvalidReplicationRule('Ask approval is not allowed for rules with multiple RSEs')
+        if len(rses) == 1 and not did.is_open and did.bytes is not None and did.length is not None:
+            # This rule can be considered for auto-approval:
+            rse_attr = list_rse_attributes(rse_id=rses[0]['id'], session=session)
+            auto_approve = False
+            if 'auto_approve_bytes' in rse_attr and 'auto_approve_files' in rse_attr:
+                if did.bytes < int(rse_attr.get('auto_approve_bytes')) and did.length < int(rse_attr.get('auto_approve_files')):
+                    auto_approve = True
+            elif did.bytes < int(rse_attr.get('auto_approve_bytes', -1)):
+                auto_approve = True
+            elif did.length < int(rse_attr.get('auto_approve_files', -1)):
+                auto_approve = True
+
+        return auto_approve
 
 
 @transactional_session
@@ -149,8 +219,8 @@ def add_rule(dids, account, copies, rse_expression, grouping, weight, lifetime,
             if source_replica_expression:
                 try:
                     source_rses = parse_expression(source_replica_expression, filter_={'vo': vo}, session=session)
-                except InvalidRSEExpression:
-                    raise InvalidSourceReplicaExpression
+                except InvalidRSEExpression as exc:
+                    raise InvalidSourceReplicaExpression from exc
             else:
                 source_rses = []
 
@@ -168,10 +238,10 @@ def add_rule(dids, account, copies, rse_expression, grouping, weight, lifetime,
                 try:
                     did = session.query(models.DataIdentifier).filter(models.DataIdentifier.scope == elem['scope'],
                                                                       models.DataIdentifier.name == elem['name']).one()
-                except NoResultFound:
-                    raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name']))
+                except NoResultFound as exc:
+                    raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name'])) from exc
                 except TypeError as error:
-                    raise InvalidObject(error.args)
+                    raise InvalidObject(error.args) from error  # https://pylint.readthedocs.io/en/latest/user_guide/messages/warning/raise-missing-from.html
 
             # 3.1 If the did is a constituent, relay the rule to the archive
             if did.did_type == DIDType.FILE and did.constituent:
@@ -193,10 +263,10 @@ def add_rule(dids, account, copies, rse_expression, grouping, weight, lifetime,
                         try:
                             did = session.query(models.DataIdentifier).filter(models.DataIdentifier.scope == elem['scope'],
                                                                               models.DataIdentifier.name == elem['name']).one()
-                        except NoResultFound:
-                            raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name']))
+                        except NoResultFound as exc:
+                            raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name'])) from exc
                         except TypeError as error:
-                            raise InvalidObject(error.args)
+                            raise InvalidObject(error.args) from error
                 else:  # Put the rule on the constituent directly
                     pass
 
@@ -243,31 +313,20 @@ def add_rule(dids, account, copies, rse_expression, grouping, weight, lifetime,
                             or match('.*IntegrityError.*duplicate key value violates unique constraint.*', error.args[0]) \
                             or match('.*UniqueViolation.*duplicate key value violates unique constraint.*', error.args[0]) \
                             or match('.*IntegrityError.*columns? .*not unique.*', error.args[0]):
-                        raise DuplicateRule(error.args[0])
-                    raise InvalidReplicationRule(error.args[0])
+                        raise DuplicateRule(error.args[0]) from error
+                    raise InvalidReplicationRule(error.args[0]) from error
                 rule_ids.append(new_rule.id)
 
             if ask_approval:
                 new_rule.state = RuleState.WAITING_APPROVAL
-                # Block manual approval for multi-rse rules
-                if len(rses) > 1:
-                    raise InvalidReplicationRule('Ask approval is not allowed for rules with multiple RSEs')
-                if len(rses) == 1 and not did.is_open and did.bytes is not None and did.length is not None:
-                    # This rule can be considered for auto-approval:
-                    rse_attr = list_rse_attributes(rse_id=rses[0]['id'], session=session)
-                    auto_approve = False
-                    if 'auto_approve_bytes' in rse_attr and 'auto_approve_files' in rse_attr:
-                        if did.bytes < int(rse_attr.get('auto_approve_bytes')) and did.length < int(rse_attr.get('auto_approve_bytes')):
-                            auto_approve = True
-                    elif did.bytes < int(rse_attr.get('auto_approve_bytes', -1)):
-                        auto_approve = True
-                    elif did.length < int(rse_attr.get('auto_approve_files', -1)):
-                        auto_approve = True
-                    if auto_approve:
-                        logger(logging.DEBUG, "Auto approving rule %s", str(new_rule.id))
-                        logger(logging.DEBUG, "Created rule %s for injection", str(new_rule.id))
-                        approve_rule(rule_id=new_rule.id, notify_approvers=False, session=session)
-                        continue
+                # Use the new rule as the argument here
+                auto_approver = AutoApprove(new_rule, did, session=session)
+                if auto_approver.evaluate():
+                    logger(logging.DEBUG, "Auto approving rule %s", str(new_rule.id))
+                    logger(logging.DEBUG, "Created rule %s for injection", str(new_rule.id))
+                    approve_rule(rule_id=new_rule.id, notify_approvers=False, session=session)
+                    continue
+
                 logger(logging.DEBUG, "Created rule %s in waiting for approval", str(new_rule.id))
                 __create_rule_approval_email(rule=new_rule, session=session)
                 continue
@@ -297,7 +356,7 @@ def add_rule(dids, account, copies, rse_expression, grouping, weight, lifetime,
                 try:
                     apply_rule(did, new_rule, [x['id'] for x in rses], [x['id'] for x in source_rses], rseselector, session=session)
                 except IntegrityError as error:
-                    raise ReplicationRuleCreationTemporaryFailed(error.args[0])
+                    raise ReplicationRuleCreationTemporaryFailed(error.args[0]) from error
 
             if new_rule.locks_stuck_cnt > 0:
                 new_rule.state = RuleState.STUCK
@@ -371,10 +430,10 @@ def add_rules(dids, rules, *, session: "Session", logger=logging.log):
                     did = session.query(models.DataIdentifier).filter(
                         models.DataIdentifier.scope == elem['scope'],
                         models.DataIdentifier.name == elem['name']).one()
-                except NoResultFound:
-                    raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name']))
+                except NoResultFound as exc:
+                    raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name'])) from exc
                 except TypeError as error:
-                    raise InvalidObject(error.args)
+                    raise InvalidObject(error.args) from error
 
             # 2.1 If the did is a constituent, relay the rule to the archive
             if did.did_type == DIDType.FILE and did.constituent:  # Check if a single replica of this DID exists
@@ -395,10 +454,10 @@ def add_rules(dids, rules, *, session: "Session", logger=logging.log):
                         try:
                             did = session.query(models.DataIdentifier).filter(models.DataIdentifier.scope == elem['scope'],
                                                                               models.DataIdentifier.name == elem['name']).one()
-                        except NoResultFound:
-                            raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name']))
+                        except NoResultFound as exc:
+                            raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (elem['scope'], elem['name'])) from exc
                         except TypeError as error:
-                            raise InvalidObject(error.args)
+                            raise InvalidObject(error.args) from error
                 else:  # Put the rule on the constituent directly
                     pass
 
@@ -498,10 +557,10 @@ def add_rules(dids, rules, *, session: "Session", logger=logging.log):
                             new_rule.save(session=session)
                         except IntegrityError as error:
                             if match('.*ORA-00001.*', str(error.args[0])):
-                                raise DuplicateRule(error.args[0])
+                                raise DuplicateRule(error.args[0]) from error
                             elif str(error.args[0]) == '(IntegrityError) UNIQUE constraint failed: rules.scope, rules.name, rules.account, rules.rse_expression, rules.copies':
-                                raise DuplicateRule(error.args[0])
-                            raise InvalidReplicationRule(error.args[0])
+                                raise DuplicateRule(error.args[0]) from error
+                            raise InvalidReplicationRule(error.args[0]) from error
 
                         rule_ids[(did.scope, did.name)].append(new_rule.id)
 
@@ -557,7 +616,7 @@ def add_rules(dids, rules, *, session: "Session", logger=logging.log):
                                                               source_rses=[rse['id'] for rse in source_rses],
                                                               session=session)
                         except IntegrityError as error:
-                            raise ReplicationRuleCreationTemporaryFailed(error.args[0])
+                            raise ReplicationRuleCreationTemporaryFailed(error.args[0]) from error
 
                     if new_rule.locks_stuck_cnt > 0:
                         new_rule.state = RuleState.STUCK
@@ -599,8 +658,8 @@ def inject_rule(rule_id, *, session: "Session", logger=logging.log):
 
     try:
         rule = session.query(models.ReplicationRule).filter(models.ReplicationRule.id == rule_id).with_for_update(nowait=True).one()
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % (rule_id))
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % (rule_id)) from exc
 
     # Check if rule will expire in the next 5 minutes:
     if rule.child_rule_id is None and rule.expires_at is not None and rule.expires_at < datetime.utcnow() + timedelta(seconds=300):
@@ -666,17 +725,17 @@ def inject_rule(rule_id, *, session: "Session", logger=logging.log):
         try:
             did = session.query(models.DataIdentifier).filter(models.DataIdentifier.scope == rule.scope,
                                                               models.DataIdentifier.name == rule.name).one()
-        except NoResultFound:
-            raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (rule.scope, rule.name))
+        except NoResultFound as exc:
+            raise DataIdentifierNotFound('Data identifier %s:%s is not valid.' % (rule.scope, rule.name)) from exc
         except TypeError as error:
-            raise InvalidObject(error.args)
+            raise InvalidObject(error.args) from error
 
     # 4. Apply the rule
     with METRICS.timer('inject_rule.apply_rule'):
         try:
             apply_rule(did, rule, [x['id'] for x in rses], [x['id'] for x in source_rses], rseselector, session=session)
         except IntegrityError as error:
-            raise ReplicationRuleCreationTemporaryFailed(error.args[0])
+            raise ReplicationRuleCreationTemporaryFailed(error.args[0]) from error
 
     if rule.locks_stuck_cnt > 0:
         rule.state = RuleState.STUCK
@@ -763,8 +822,8 @@ def list_rules(filters={}, *, session: "Session"):
             d = rule.to_dict()
             d['bytes'] = data_identifier_bytes
             yield d
-    except StatementError:
-        raise RucioException('Badly formatted input (IDs?)')
+    except StatementError as exc:
+        raise RucioException('Badly formatted input (IDs?)') from exc
 
 
 @stream_session
@@ -786,8 +845,8 @@ def list_rule_history(rule_id, *, session: "Session"):
     try:
         for rule in query.yield_per(5):
             yield {'updated_at': rule[0], 'state': rule[1], 'locks_ok_cnt': rule[2], 'locks_stuck_cnt': rule[3], 'locks_replicating_cnt': rule[4]}
-    except StatementError:
-        raise RucioException('Badly formatted input (IDs?)')
+    except StatementError as exc:
+        raise RucioException('Badly formatted input (IDs?)') from exc
 
 
 @stream_session
@@ -838,8 +897,8 @@ def list_associated_rules_for_file(scope, name, *, session: "Session"):
     try:
         for rule in query.yield_per(5):
             yield rule.to_dict()
-    except StatementError:
-        raise RucioException('Badly formatted input (IDs?)')
+    except StatementError as exc:
+        raise RucioException('Badly formatted input (IDs?)') from exc
 
 
 @transactional_session
@@ -864,8 +923,8 @@ def delete_rule(rule_id, purge_replicas=None, soft=False, delete_parent=False, n
             rule = session.query(models.ReplicationRule)\
                           .filter(models.ReplicationRule.id == rule_id)\
                           .with_for_update(nowait=nowait).one()
-        except NoResultFound:
-            raise RuleNotFound('No rule with the id %s found' % rule_id)
+        except NoResultFound as exc:
+            raise RuleNotFound('No rule with the id %s found' % rule_id) from exc
         if rule.locked and not ignore_rule_lock:
             raise UnsupportedOperation('The replication rule is locked and has to be unlocked before it can be deleted.')
 
@@ -1171,10 +1230,10 @@ def get_rule(rule_id, *, session: "Session"):
     try:
         rule = session.query(models.ReplicationRule).filter_by(id=rule_id).one()
         return rule.to_dict()
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % (rule_id))
-    except StatementError:
-        raise RucioException('Badly formatted rule id (%s)' % (rule_id))
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % (rule_id)) from exc
+    except StatementError as exc:
+        raise RucioException('Badly formatted rule id (%s)' % (rule_id)) from exc
 
 
 @transactional_session
@@ -1409,8 +1468,8 @@ def update_rule(rule_id: str, options: dict[str, Any], *, session: "Session") ->
                     rule.priority = options[key]
                     transfers_to_update = request_core.update_requests_priority(priority=options[key], filter_={'rule_id': rule_id}, session=session)
                     transfer_core.update_transfer_priority(transfers_to_update)
-                except Exception:
-                    raise UnsupportedOperation('The FTS Requests are already in a final state.')
+                except Exception as exc:
+                    raise UnsupportedOperation('The FTS Requests are already in a final state.') from exc
 
             elif key == 'child_rule_id':
                 # Check if the child rule has the same scope/name as the parent rule
@@ -1470,13 +1529,13 @@ def update_rule(rule_id: str, options: dict[str, Any], *, session: "Session") ->
                 or match('.*IntegrityError.*UNIQUE constraint failed.*', str(error.args[0])) \
                 or match('.*1062.*Duplicate entry.*for key.*', str(error.args[0])) \
                 or match('.*IntegrityError.*columns? .*not unique.*', str(error.args[0])):
-            raise DuplicateRule(error.args[0])
+            raise DuplicateRule(error.args[0]) from error
         else:
             raise error
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % (rule_id))
-    except StatementError as e:
-        raise RucioException(f"A StatementError occurred while processing rule {rule_id}") from e
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % (rule_id)) from exc
+    except StatementError as exc:
+        raise RucioException(f"A StatementError occurred while processing rule {rule_id}") from exc
 
 
 @transactional_session
@@ -1541,8 +1600,8 @@ def reduce_rule(rule_id, copies, exclude_expression=None, *, session: "Session")
 
         return new_rule_id[0]
 
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % (rule_id))
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % (rule_id)) from exc
 
 
 @transactional_session
@@ -1608,10 +1667,10 @@ def move_rule(rule_id: str, rse_expression: str, override: Optional[dict[str, An
 
         return new_rule_id[0]
 
-    except StatementError:
-        raise RucioException('Badly formatted rule id (%s)' % (rule_id))
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % (rule_id))
+    except StatementError as exc:
+        raise RucioException('Badly formatted rule id (%s)' % (rule_id)) from exc
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % (rule_id)) from exc
 
 
 @transactional_session
@@ -1629,8 +1688,8 @@ def re_evaluate_did(scope, name, rule_evaluation_action, *, session: "Session"):
     try:
         did = session.query(models.DataIdentifier).filter(models.DataIdentifier.scope == scope,
                                                           models.DataIdentifier.name == name).one()
-    except NoResultFound:
-        raise DataIdentifierNotFound()
+    except NoResultFound as exc:
+        raise DataIdentifierNotFound() from exc
 
     if rule_evaluation_action == DIDReEvaluation.ATTACH:
         __evaluate_did_attach(did, session=session)
@@ -2235,10 +2294,10 @@ def approve_rule(rule_id, approver=None, notify_approvers=True, *, session: "Ses
                                          'to': [recipient[0]],
                                          'subject': 'Re: [RUCIO] Request to approve replication rule %s' % (str(rule.id))},
                                 session=session)
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % (rule_id))
-    except StatementError:
-        raise RucioException('Badly formatted rule id (%s)' % (rule_id))
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % (rule_id)) from exc
+    except StatementError as exc:
+        raise RucioException('Badly formatted rule id (%s)' % (rule_id)) from exc
 
 
 @transactional_session
@@ -2294,10 +2353,10 @@ def deny_rule(rule_id, approver=None, reason=None, *, session: "Session"):
                                      'to': [recipient[0]],
                                      'subject': 'Re: [RUCIO] Request to approve replication rule %s' % (str(rule.id))},
                             session=session)
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % rule_id)
-    except StatementError:
-        raise RucioException('Badly formatted rule id (%s)' % rule_id)
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % rule_id) from exc
+    except StatementError as exc:
+        raise RucioException('Badly formatted rule id (%s)' % rule_id) from exc
 
 
 @transactional_session
@@ -2351,10 +2410,10 @@ def examine_rule(rule_id, *, session: "Session"):
                                             'sources': sources,
                                             'last_time': last_time})
         return result
-    except NoResultFound:
-        raise RuleNotFound('No rule with the id %s found' % (rule_id))
-    except StatementError:
-        raise RucioException('Badly formatted rule id (%s)' % (rule_id))
+    except NoResultFound as exc:
+        raise RuleNotFound('No rule with the id %s found' % (rule_id)) from exc
+    except StatementError as exc:
+        raise RucioException('Badly formatted rule id (%s)' % (rule_id)) from exc
 
 
 @transactional_session


Test patch: