Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type annotations and refactor to use TokenDict type #6497

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
96 changes: 96 additions & 0 deletions lib/rucio/common/types.py
Expand Up @@ -13,8 +13,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from datetime import datetime
from typing import Any, Callable, Literal, Optional, TypedDict, Union

from rucio.db.sqla.constants import AccountType, IdentityType


class InternalType(object):
'''
Expand Down Expand Up @@ -174,3 +177,96 @@ class RuleDict(TypedDict):
activity: str
notify: Optional[Literal['Y', 'N', 'C']]
purge_replicas: bool


class RSEAccountCounterDict(TypedDict):
account: InternalAccount
rse_id: str


class RSEAccountUsageDict(TypedDict):
rse_id: str
rse: str
account: InternalAccount
used_files: int
used_bytes: int
quota_bytes: int


class RSEGlobalAccountUsageDict(TypedDict):
rse_expression: str
bytes: int
files: int
bytes_limit: int
bytes_remaining: int


class RSELocalAccountUsageDict(TypedDict):
rse_id: str
rse: str
bytes: int
files: int
bytes_limit: int
bytes_remaining: int


class RSEResolvedGlobalAccountLimitDict(TypedDict):
resolved_rses: str
resolved_rse_ids: list[str]
limit: float


class TokenDict(TypedDict):
token: str
expires_at: datetime


class TokenValidationDict(TypedDict):
account: Optional[InternalAccount]
identity: Optional[str]
lifetime: Optional[datetime]
audience: Optional[str]
authz_scope: Optional[str]


class AccountDict(TypedDict):
account: InternalAccount
type: AccountType
email: str


class AccountAttributesDict(TypedDict):
key: str
value: Union[bool, str]


class IdentityDict(TypedDict):
type: IdentityType
identity: str
email: str


class UsageDict(TypedDict):
bytes: int
files: int
updated_at: Optional[datetime]


class TokenOIDCAutoDict(TypedDict, total=False):
webhome: Optional[str]
token: Optional[TokenDict]


class TokenOIDCNoAutoDict(TypedDict):
fetchcode: str


class TokenOIDCPollingDict(TypedDict):
polling: bool


class AccountUsageModelDict(TypedDict):
account: InternalAccount
rse_id: str
files: int
bytes: int
11 changes: 5 additions & 6 deletions lib/rucio/common/utils.py
Expand Up @@ -40,7 +40,7 @@
from functools import partial, wraps
from io import StringIO
from itertools import zip_longest
from typing import TYPE_CHECKING, Callable, Optional
from typing import Any, Callable, Optional, TYPE_CHECKING
from urllib.parse import urlparse, urlencode, quote, parse_qsl, urlunparse
from uuid import uuid4 as uuid
from xml.etree import ElementTree
Expand Down Expand Up @@ -568,7 +568,7 @@ def str_to_date(string):
return datetime.datetime.strptime(string, DATE_FORMAT) if string else None


def val_to_space_sep_str(vallist):
def val_to_space_sep_str(vallist: Any) -> str:
""" Converts a list of values into a string of space separated values

:param vallist: the list of values to to convert into string
Expand Down Expand Up @@ -1198,22 +1198,21 @@ def detect_client_location():
'longitude': longitude}


def ssh_sign(private_key, message):
def ssh_sign(private_key: str, message: str) -> str:
"""
Sign a string message using the private key.

:param private_key: The SSH RSA private key as a string.
:param message: The message to sign as a string.
:return: Base64 encoded signature as a string.
"""
if isinstance(message, str):
message = message.encode()
encoded_message = message.encode()
if not EXTRA_MODULES['paramiko']:
raise MissingModuleException('The paramiko module is not installed or faulty.')
sio_private_key = StringIO(private_key)
priv_k = RSAKey.from_private_key(sio_private_key)
sio_private_key.close()
signature_stream = priv_k.sign_ssh_data(message)
signature_stream = priv_k.sign_ssh_data(encoded_message)
signature_stream.rewind()
base64_encoded = base64.b64encode(signature_stream.get_remainder())
base64_encoded = base64_encoded.decode()
Expand Down
58 changes: 29 additions & 29 deletions lib/rucio/core/account.py
Expand Up @@ -17,7 +17,7 @@
from enum import Enum
from re import match
from traceback import format_exc
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast, Iterator, Optional

from sqlalchemy import select, and_
from sqlalchemy.exc import IntegrityError
Expand All @@ -27,6 +27,7 @@
import rucio.core.rse
from rucio.common import exception
from rucio.common.config import config_get_bool
from rucio.common.types import InternalAccount, AccountAttributesDict, AccountDict, AccountUsageModelDict, IdentityDict, UsageDict
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use isort if you don’t want to do the work manually. 😄

from rucio.core.vo import vo_exists
from rucio.db.sqla import models
from rucio.db.sqla.constants import AccountStatus, AccountType
Expand All @@ -37,7 +38,7 @@


@transactional_session
def add_account(account, type_, email, *, session: "Session"):
def add_account(account: InternalAccount, type_: AccountType, email: str, *, session: "Session") -> None:
""" Add an account with the given account name and type.

:param account: the name of the new account.
Expand All @@ -63,7 +64,7 @@ def add_account(account, type_, email, *, session: "Session"):


@read_session
def account_exists(account, *, session: "Session"):
def account_exists(account: InternalAccount, *, session: "Session") -> bool:
""" Checks to see if account exists and is active.

:param account: Name of the account.
Expand All @@ -81,7 +82,7 @@ def account_exists(account, *, session: "Session"):


@read_session
def get_account(account, *, session: "Session"):
def get_account(account: InternalAccount, *, session: "Session") -> models.Account:
""" Returns an account for the given account name.

:param account: the name of the account.
Expand All @@ -102,7 +103,7 @@ def get_account(account, *, session: "Session"):


@transactional_session
def del_account(account, *, session: "Session"):
def del_account(account: InternalAccount, *, session: "Session") -> None:
""" Disable an account with the given account name.

:param account: the account name.
Expand All @@ -115,15 +116,15 @@ def del_account(account, *, session: "Session"):
models.Account.status == AccountStatus.ACTIVE
)
try:
account = session.execute(query).scalar_one()
account_result = session.execute(query).scalar_one()
except exc.NoResultFound:
raise exception.AccountNotFound('Account with ID \'%s\' cannot be found' % account)

account.update({'status': AccountStatus.DELETED, 'deleted_at': datetime.utcnow()})
account_result.update({'status': AccountStatus.DELETED, 'deleted_at': datetime.utcnow()})


@transactional_session
def update_account(account, key, value, *, session: "Session"):
def update_account(account: InternalAccount, key: str, value: Any, *, session: "Session") -> None:
""" Update a property of an account.

:param account: Name of the account.
Expand All @@ -137,22 +138,22 @@ def update_account(account, key, value, *, session: "Session"):
models.Account.account == account
)
try:
account = session.execute(query).scalar_one()
query_result = session.execute(query).scalar_one()
except exc.NoResultFound:
raise exception.AccountNotFound('Account with ID \'%s\' cannot be found' % account)
if key == 'status':
if isinstance(value, str):
value = AccountStatus[value]
if value == AccountStatus.SUSPENDED:
account.update({'status': value, 'suspended_at': datetime.utcnow()})
query_result.update({'status': value, 'suspended_at': datetime.utcnow()})
elif value == AccountStatus.ACTIVE:
account.update({'status': value, 'suspended_at': None})
query_result.update({'status': value, 'suspended_at': None})
else:
account.update({key: value})
query_result.update({key: value})


@stream_session
def list_accounts(filter_=None, *, session: "Session"):
def list_accounts(filter_: Optional[dict[str, Any]] = None, *, session: "Session") -> Iterator[AccountDict]:
""" Returns a list of all account names.

:param filter_: Dictionary of attributes by which the input data should be filtered
Expand Down Expand Up @@ -213,7 +214,7 @@ def list_accounts(filter_=None, *, session: "Session"):


@read_session
def list_identities(account, *, session: "Session"):
def list_identities(account: InternalAccount, *, session: "Session") -> list[IdentityDict]:
"""
List all identities on an account.

Expand Down Expand Up @@ -244,11 +245,11 @@ def list_identities(account, *, session: "Session"):
).where(
models.IdentityAccountAssociation.account == account
)
return [row._asdict() for row in session.execute(query)]
return [cast(IdentityDict, row._asdict()) for row in session.execute(query)]
Comment on lines -247 to +248
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not so much a comment for you, but more so for me: understand the necessity to use cast().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what occurs when I don't use cast:

Found 5 new problems in /home/runner/work/rucio/rucio/lib/rucio/core/account.py
  - 1 errors with message """Expression of type "list[Dict[str, Any]]" cannot be assigned to return type "list[IdentityDict]"""".
    Candidates: line 248
  - 1 errors with message """Expression of type "list[Dict[str, Any]]" cannot be assigned to return type "list[AccountAttributesDict]"""".
    Candidates: line 278
  - 1 errors with message """Expression of type "Dict[str, Any]" cannot be assigned to return type "UsageDict"
                               "Dict[str, Any]" is incompatible with "UsageDict"""".
    Candidates: line 377
  - 1 errors with message """Expression of type "list[dict[str, Any]]" cannot be assigned to return type "list[AccountUsageModelDict]"""".
    Candidates: line 398
  - 1 errors with message """Expression of type "list[Dict[str, Any]]" cannot be assigned to return type "list[UsageDict]"""".
    Candidates: line 424

See here for reference: https://github.com/rdimaio/rucio/actions/runs/8206991793/job/22447307300#step:9:10

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the Dict[str, Any] hint must be coming from SQLAlchemy itself. But by using cast() we effectively lose type checking:

$ cat --number test.py
     1	#!/usr/bin/env python3
     2	
     3	from typing import TypedDict, cast
     4	
     5	class Struct(TypedDict):
     6	    foo: str
     7	    bar: int
     8	
     9	def test1() -> Struct:
    10	    return {'foo': 'foo', 'bar': 0}
    11	
    12	def test2() -> Struct:
    13	    return {'baz': None}
    14	
    15	def test3() -> Struct:
    16	    return cast(Struct, {'baz': None})
$ mypy test.py 
test.py:13: error: Missing keys ("foo", "bar") for TypedDict "Struct"  [typeddict-item]
test.py:13: error: Extra key "baz" for TypedDict "Struct"  [typeddict-unknown-key]
Found 2 errors in 1 file (checked 1 source file)

Does that mean that we need to avoid the use of _asdict() and to_dict() altogether? How annoying.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using cast, this seems to work fine:

UsageDict(**session.execute(query).one()._asdict())

see test commit for reference: rdimaio@cff7fa2

And pyright report: https://github.com/rdimaio/rucio/actions/runs/8233368875/job/22512678528

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m afraid that I failed to verify that it will raise an error in case of a mismatch:

$ cat --number test.py 
     1	#!/usr/bin/env python3
     2	
     3	from typing import TypedDict
     4	
     5	from sqlalchemy import select
     6	from sqlalchemy.orm import Session
     7	
     8	from rucio.db.sqla.models import Identity
     9	from rucio.db.sqla.session import read_session
    10	
    11	
    12	# This is wrong.
    13	class IdentityDict(TypedDict):
    14	    foo: str
    15	    bar: int
    16	
    17	
    18	@read_session
    19	def list_identities(*, session: Session) -> list[IdentityDict]:
    20	    query = select(
    21	        Identity.identity
    22	    )
    23	    return [IdentityDict(**result._asdict()) for result in session.execute(query)]
    24	
    25	l = list_identities()
$ pyright test.py 
0 errors, 0 warnings, 0 informations

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6. Incompatible TypedDict, passing parameters directly, no extra parameters

#!/usr/bin/env python3
from typing import Any, TypedDict

class IncompatibleDict(TypedDict):
    foo: str
    bar: int

def print_dict() -> IncompatibleDict:
    test_dict = {
        "name": "test",
        "version": 3
    }
    d = IncompatibleDict(foo=test_dict['name'], bar=test_dict["version"])
    return(d)


l = print_dict()

Pyright doesn't complain

❯ python3 -m pyright lib/rucio/core/test.py                                                                                                       ─╯
0 errors, 0 warnings, 0 informations 

Mypy complains

❯ python3 -m mypy lib/rucio/core/test.py                                                                                                          ─╯
lib/rucio/core/test.py:13: error: Incompatible types (expression has type "object", TypedDict item "foo" has type "str")  [typeddict-item]
lib/rucio/core/test.py:13: error: Incompatible types (expression has type "object", TypedDict item "bar" has type "int")  [typeddict-item]
Found 2 errors in 1 file (checked 1 source file)

7. Incompatible TypedDict, passing parameters directly, extra parameters

#!/usr/bin/env python3
from typing import Any, TypedDict

class IncompatibleDict(TypedDict):
    foo: str
    bar: int

def print_dict() -> IncompatibleDict:
    test_dict = {
        "name": "test",
        "version": 3
    }
    d = IncompatibleDict(foo=test_dict['name'], bar=test_dict["version"], extra="unexpected")
    return(d)


l = print_dict()

Pyright complains

❯ python3 -m pyright lib/rucio/core/test.py                                                                                                       ─╯
/home/rdm/tech/rucio/rucio/lib/rucio/core/test.py
  /home/rdm/tech/rucio/rucio/lib/rucio/core/test.py:13:9 - error: No overloads for "__init__" match the provided arguments
    Argument types: (Unknown, Unknown, Literal['unexpected']) (reportCallIssue)
1 error, 0 warnings, 0 informations 

Mypy complains

❯ python3 -m mypy lib/rucio/core/test.py                                                                                                          ─╯
lib/rucio/core/test.py:13: error: Extra key "extra" for TypedDict "IncompatibleDict"  [typeddict-unknown-key]
lib/rucio/core/test.py:13: error: Incompatible types (expression has type "object", TypedDict item "foo" has type "str")  [typeddict-item]
lib/rucio/core/test.py:13: error: Incompatible types (expression has type "object", TypedDict item "bar" has type "int")  [typeddict-item]
Found 3 errors in 1 file (checked 1 source file)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8. Incompatible Pydantic model, passing parameters directly, static type checking

#!/usr/bin/env python3
from pydantic import BaseModel, ConfigDict

class IncompatibleModel(BaseModel):
    foo: int
    bar: int

def print_class() -> IncompatibleModel:
    test_dict = {
        "name": "test",
        "version": 3
    }
    return(IncompatibleModel(foo=test_dict['name'], bar=test_dict['version']))

l = print_class()

Pydantic complains here, as we saw earlier. From the point of view of the static type checkers:

Pyright doesn't complain

❯ python3 -m pyright lib/rucio/core/test.py                                                                                                       ─╯
0 errors, 0 warnings, 0 informations 

Mypy complains

❯ python3 -m mypy lib/rucio/core/test.py                                                                                                          ─╯
lib/rucio/core/test.py:13: error: Argument "foo" to "IncompatibleModel" has incompatible type "object"; expected "int"  [arg-type]
lib/rucio/core/test.py:13: error: Argument "bar" to "IncompatibleModel" has incompatible type "object"; expected "int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9. Incompatible dataclass, passing parameters directly

#!/usr/bin/env python3
from typing import Any, cast
from dataclasses import dataclass, field

@dataclass
class IncompatibleClass:
    foo: int
    bar: int

def print_class() -> IncompatibleClass:
    test_dict = {
        "name": "test",
        "version": 3
    }
    d = IncompatibleClass(foo=test_dict['name'], bar=test_dict['version'])
    return(d)


l = print_class()

Pyright doesn't complain

❯ python3 -m pyright lib/rucio/core/test.py                                                                                                       ─╯
0 errors, 0 warnings, 0 informations 

Mypy complains (ambiguously)

❯ python3 -m mypy lib/rucio/core/test.py                                                                                                          ─╯
lib/rucio/core/test.py:15: error: Argument "foo" to "IncompatibleClass" has incompatible type "object"; expected "int"  [arg-type]
lib/rucio/core/test.py:15: error: Argument "bar" to "IncompatibleClass" has incompatible type "object"; expected "int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Mypy complains even if I change foo to str - its problem seems to be that the type for foo and bar is not explicitly mentioned.

10. Incompatible dataclass, passing parameters directly

#!/usr/bin/env python3
from typing import Any, cast
from dataclasses import dataclass, field

@dataclass
class IncompatibleClass:
    foo: int
    bar: int

def print_class() -> IncompatibleClass:
    name = "test"
    version = 3
    d = IncompatibleClass(foo=name, bar=version)
    return(d)

l = print_class()

Pyright complains

❯ python3 -m pyright lib/rucio/core/test.py                                                                                                       ─╯
/home/rdm/tech/rucio/rucio/lib/rucio/core/test.py
  /home/rdm/tech/rucio/rucio/lib/rucio/core/test.py:13:31 - error: Argument of type "Literal['test']" cannot be assigned to parameter "foo" of type "int" in function "__init__"
    "Literal['test']" is incompatible with "int" (reportArgumentType)
1 error, 0 warnings, 0 informations 

Mypy complains

❯ python3 -m mypy lib/rucio/core/test.py                                                                                                          ─╯
lib/rucio/core/test.py:13: error: Argument "foo" to "IncompatibleClass" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

11. Incompatible TypedDict, passing parameters directly, adding explicitly types for the input arguments

#!/usr/bin/env python3
from typing import Any, TypedDict

class IncompatibleDict(TypedDict):
    foo: int
    bar: int

def print_dict() -> IncompatibleDict:
    name: str = "test"
    version: int = 3
    d = IncompatibleDict(foo=name, bar=version)
    return(d)


l = print_dict()

Pyright complains

❯ python3 -m pyright lib/rucio/core/test.py                                                                                                       ─╯
/home/rdm/tech/rucio/rucio/lib/rucio/core/test.py
  /home/rdm/tech/rucio/rucio/lib/rucio/core/test.py:11:30 - error: Argument of type "Literal['test']" cannot be assigned to parameter "foo" of type "int" in function "__init__"
    "Literal['test']" is incompatible with "int" (reportArgumentType)
1 error, 0 warnings, 0 informations 

Mypy complains

❯ python3 -m mypy lib/rucio/core/test.py                                                                                                          ─╯
lib/rucio/core/test.py:11: error: Incompatible types (expression has type "str", TypedDict item "foo" has type "int")  [typeddict-item]
Found 1 error in 1 file (checked 1 source file)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conclusion

  • Static type checking: mypy seems to be slightly stricter compared to pyright - I think it would be worth considering we move to mypy.
  • Runtime type checking: I think this should be necessary to catch cases where static type checking would not be enough. I opened an issue to discuss this: Runtime type checking, Pydantic, SQLModel #6544

In the long term, I think the best approach would be to use mypy+pydantic and use pydantic models for struct typing. For now, it seems to be fine to use TypedDict, passing arguments directly, instead of passing them as kwargs, and adding explicit type annotations for the input arguments, so that the static type checkers are able to notice any issues.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relevant thread #6615



@read_session
def list_account_attributes(account, *, session: "Session"):
def list_account_attributes(account: InternalAccount, *, session: "Session") -> list[AccountAttributesDict]:
"""
Get all attributes defined for an account.

Expand All @@ -274,11 +275,11 @@ def list_account_attributes(account, *, session: "Session"):
).where(
models.AccountAttrAssociation.account == account
)
return [row._asdict() for row in session.execute(query)]
return [cast(AccountAttributesDict, row._asdict()) for row in session.execute(query)]


@read_session
def has_account_attribute(account, key, *, session: "Session"):
def has_account_attribute(account: InternalAccount, key: str, *, session: "Session") -> bool:
"""
Indicates whether the named key is present for the account.

Expand All @@ -298,7 +299,7 @@ def has_account_attribute(account, key, *, session: "Session"):


@transactional_session
def add_account_attribute(account, key, value, *, session: "Session"):
def add_account_attribute(account: InternalAccount, key: str, value: Any, *, session: "Session") -> None:
"""
Add an attribute for the given account name.

Expand Down Expand Up @@ -334,7 +335,7 @@ def add_account_attribute(account, key, value, *, session: "Session"):


@transactional_session
def del_account_attribute(account, key, *, session: "Session"):
def del_account_attribute(account: InternalAccount, key: str, *, session: "Session") -> None:
"""
Add an attribute for the given account name.

Expand All @@ -355,7 +356,7 @@ def del_account_attribute(account, key, *, session: "Session"):


@read_session
def get_usage(rse_id, account, *, session: "Session"):
def get_usage(rse_id: str, account: InternalAccount, *, session: "Session") -> UsageDict:
"""
Returns current values of the specified counter, or raises CounterNotFound if the counter does not exist.

Expand All @@ -373,13 +374,13 @@ def get_usage(rse_id, account, *, session: "Session"):
models.AccountUsage.account == account
)
try:
return session.execute(query).one()._asdict()
return cast(UsageDict, session.execute(query).one()._asdict())
except exc.NoResultFound:
return {'bytes': 0, 'files': 0, 'updated_at': None}
return UsageDict({'bytes': 0, 'files': 0, 'updated_at': None})
Comment on lines -378 to +379
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replied here: #6497 (comment)



@read_session
def get_all_rse_usages_per_account(account, *, session: "Session"):
def get_all_rse_usages_per_account(account: InternalAccount, *, session: "Session") -> list[AccountUsageModelDict]:
"""
Returns current values of the specified counter, or raises CounterNotFound if the counter does not exist.

Expand All @@ -394,20 +395,20 @@ def get_all_rse_usages_per_account(account, *, session: "Session"):
models.AccountUsage.account == account
)
try:
return [result.to_dict() for result in session.execute(query).scalars()]
return [cast(AccountUsageModelDict, result.to_dict()) for result in session.execute(query).scalars()]
except exc.NoResultFound:
return []


@read_session
def get_usage_history(rse_id, account, *, session: "Session"):
def get_usage_history(rse_id: str, account: InternalAccount, *, session: "Session") -> list[UsageDict]:
"""
Returns historical values of the specified counter, or raises CounterNotFound if the counter does not exist.

:param rse_id: The id of the RSE.
:param account: The account name.
:param session: The database session in use.
:returns: A dictionary {'bytes', 'files', 'updated_at'}
:returns: A list of dictionaries {'bytes', 'files', 'updated_at'}
"""
query = select(
models.AccountUsageHistory.bytes,
Expand All @@ -420,7 +421,6 @@ def get_usage_history(rse_id, account, *, session: "Session"):
models.AccountUsageHistory.updated_at
)
try:
return [row._asdict() for row in session.execute(query)]
return [cast(UsageDict, row._asdict()) for row in session.execute(query)]
except exc.NoResultFound:
raise exception.CounterNotFound('No usage can be found for account %s on RSE %s' % (account, rucio.core.rse.get_rse_name(rse_id=rse_id, session=session)))
return []