From 0659eca1d8c54cec7b6c7e434332cb86a364bc92 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Mon, 9 Mar 2026 04:51:56 -0400 Subject: [PATCH 1/2] [_120,_801] ticket attributes and ticket_iterator --- README.md | 56 +++++++++++++++++++++++++++++ irods/models.py | 5 ++- irods/test/ticket_test.py | 58 +++++++++++++++++++++++++----- irods/ticket.py | 74 +++++++++++++++++++++++++++++++-------- 4 files changed, 166 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index eaae0a688..7443b56e1 100644 --- a/README.md +++ b/README.md @@ -1850,6 +1850,62 @@ relations: ['1636757427'] ``` +Listing All Tickets +------------------- + +In PRC v3.3.0 and later, Ticket objects constructed using a query `result` will contain +attributes (such as `id`, `string`, `create_time`, `modify_time`, etc.) reflecting +the columns fetched from that query. + +As before, of course, we can still construct the 'raw' Ticket object from a session object and +optional ticket string (leaving out the `result` parameter) and then use that object to generate +a new ticket or delete an existing ticket of the given name. + +A free function also exists, irods.ticket.ticket_iterator, which by default (like `iticket ls`) +iterates over a query of the `TicketQuery.Ticket` model to enumerate the set of +ticket objects visible to the current user. Note that for a rodsadmin, this will include +not only their own tickets, but (unlike `iticket ls`) also those generated by rodsusers as well, including +possibly some tickets belonging to users no longer existing. To avoid this, we can query in +following manner: + +```py +from pprint import pp +from irods.ticket import TicketQuery +pp([vars(t) for t in + ticket_iterator(session, filter_args=[TicketQuery.Owner.name != '']) +]) +``` + +The above should produce a listing fairly close to what one would expect from `iticket ls`. + +How can a rodsadmin selectively access "orphan" tickets (those no longer owned by any user)? + +Although one cannot filter a Genquery1-type query through the negation of the above filter, +i.e. `TicketQuery.Owner.name == ''`, there are other ways to accomplish the same result. +For example, to clean the system of the orphan tickets (those from users which have been deleted), +we can do something like this: + +```py +import collections, itertools +import irods.keywords as kw +from irods.models import User +from irods.ticket import ticket_iterator + +def delete_ticket_and_ignore_result(ticket): + ticket.delete(**{kw.ADMIN_KW:''}) + return True + +existing_users = [row[User.id] for row in session.query(User.id)] + +# 'deque' will exhaust the ticket iterator without the storage overhead of 'list' or 'tuple' +collections.deque( + itertools.dropwhile( + delete_ticket_and_ignore_result, + (_ for _ in ticket_iterator(session) if _.user_id not in existing_users) + ), + maxlen=0) +``` + Tracking and manipulating replicas of Data Objects -------------------------------------------------- diff --git a/irods/models.py b/irods/models.py index d779bf86b..889053540 100644 --- a/irods/models.py +++ b/irods/models.py @@ -243,9 +243,8 @@ class Ticket(Model): write_byte_count = Column(Integer, "TICKET_WRITE_BYTE_COUNT", 2213) write_byte_limit = Column(Integer, "TICKET_WRITE_BYTE_LIMIT", 2214) - ## For now, use of these columns raises CAT_SQL_ERR in both PRC and iquest: (irods/irods#5929) - # create_time = Column(String, 'TICKET_CREATE_TIME', 2209) - # modify_time = Column(String, 'TICKET_MODIFY_TIME', 2210) + create_time = Column(DateTime, 'TICKET_CREATE_TIME', 2209, min_version=(4, 3, 0)) + modify_time = Column(DateTime, 'TICKET_MODIFY_TIME', 2210, min_version=(4, 3, 0)) class DataObject(Model): """For queries of R_DATA_MAIN when joining to R_TICKET_MAIN. diff --git a/irods/test/ticket_test.py b/irods/test/ticket_test.py index 56e7c3a10..5e86e15fc 100644 --- a/irods/test/ticket_test.py +++ b/irods/test/ticket_test.py @@ -1,19 +1,19 @@ #! /usr/bin/env python +import calendar +import datetime import os import sys -import unittest +import tempfile import time -import calendar +import unittest -import irods.test.helpers as helpers -import tempfile -from irods.session import iRODSSession import irods.exception as ex import irods.keywords as kw -from irods.ticket import Ticket -from irods.models import TicketQuery, DataObject, Collection - +from irods.models import Collection, DataObject, TicketQuery +from irods.session import iRODSSession +from irods.test import helpers +from irods.ticket import Ticket, ticket_iterator # As with most of the modules in this test suite, session objects created via # make_session() are implicitly agents of a rodsadmin unless otherwise indicated. @@ -48,7 +48,6 @@ def login(self, user): user=user.name, password=self.users[user.name], ) - @staticmethod def irods_homedir(sess, path_only=False): path = f"/{sess.zone}/home/{sess.username}" @@ -73,6 +72,8 @@ def setUp(self): u = ses.users.get(ses.username) if u.type != "rodsadmin": self.skipTest("""Test runnable only by rodsadmin.""") + self.rods_admin_name = ses.username + self.host = ses.host self.port = ses.port self.zone = ses.zone @@ -358,6 +359,27 @@ def test_coll_read_ticket_between_rodsusers(self): os.unlink(file_.name) alice.cleanup() + def test_modify_time_and_create_time_attributes_in_tickets__issue_801(self): + # Specifically we are testing that 'modify_time' and 'create_time' attributes function as expected, + + bobs_ticket = None + + try: + with self.login(self.bob) as bob: + bobs_ticket = Ticket(bob).issue('write', helpers.home_collection(bob)) + time.sleep(2) + bobs_ticket.modify('add', 'user', self.rods_admin_name) + + # Reload the ticket, this time with the full complement of attributes present. + bobs_ticket = next(ticket_iterator(bob, filter_args=[TicketQuery.Ticket.string == bobs_ticket.string])) + + self.assertGreaterEqual( + bobs_ticket.modify_time, bobs_ticket.create_time + datetime.timedelta(seconds=1) + ) + finally: + if bobs_ticket: + bobs_ticket.delete() + class TestTicketOps(unittest.TestCase): @@ -455,6 +477,24 @@ def test_data_ticket_write(self): def test_coll_ticket_write(self): self._ticket_write_helper(obj_type="coll") + def test_ticket_iterator__issue_120(self): + + ses = self.sess + t = None + + try: + # t first assigned as a "utility" Ticket object + t = Ticket(ses).issue('read', helpers.home_collection(ses)) + + # This time, t receives attributes from a query result: notably the id, which we use for the next test. + t = Ticket(ses, result=ses.query(TicketQuery.Ticket).filter(TicketQuery.Ticket.string == t.string).one()) + + # Check an id attribute is present and listed in the results from list_tickets + self.assertIn(t.id, (ticket.id for ticket in ticket_iterator(ses))) + finally: + if t: + t.delete() + if __name__ == "__main__": # let the tests find the parent irods lib diff --git a/irods/ticket.py b/irods/ticket.py index 5a213dbcb..ba07ecbda 100644 --- a/irods/ticket.py +++ b/irods/ticket.py @@ -1,15 +1,14 @@ -from irods.api_number import api_number -from irods.message import iRODSMessage, TicketAdminRequest -from irods.models import TicketQuery - +import calendar +import contextlib +import datetime import random import string -import logging -import datetime -import calendar - +from typing import Any, Optional, Type, Union # noqa: UP035 -logger = logging.getLogger(__name__) +from irods.api_number import api_number +from irods.column import Column +from irods.message import TicketAdminRequest, iRODSMessage +from irods.models import TicketQuery def get_epoch_seconds(utc_timestamp): @@ -28,16 +27,61 @@ def get_epoch_seconds(utc_timestamp): raise # final try at conversion, so a failure is an error +def ticket_iterator(session, filter_args=()): + """ + Enumerate the Tickets visible to the user. + + Args: + session: an iRODSSession object with which to perform a query. + filter_args: optional arguments for filtering the query. + + Returns: + An iterator over a range of Ticket objects. + """ + return (Ticket(session, result=row) for row in session.query(TicketQuery.Ticket).filter(*filter_args)) + + +_COLUMN_KEY = Union[Column, Type[Column]] # noqa: UP006 + + class Ticket: - def __init__(self, session, ticket="", result=None, allow_punctuation=False): + def __init__(self, session, ticket="", result: Optional[dict[_COLUMN_KEY, Any]] = None, allow_punctuation=False): # noqa: FA100 + """ + Initialize a Ticket object. If no 'result' or 'ticket' string is provided, then generate a new + Ticket string automatically. + + Args: + session: an iRODSSession object through which API endpoints shall be called. + ticket: an optional ticket string, if a particular one is desired for ticket creation or deletion. + result: a row result from a query, containing at least the columns of irods.models.TicketQuery.Ticket. + allow_punctuation: True if punctuation characters are to be allowed in generating a Ticket string. + (By default, all characters will be digits or letters of the latin alphabet.) + + Raises: + RuntimeError: if the given ticket parameter mismatches the result, or if result is of the wrong type. + """ self._session = session + + # Do an initial error and sanity check on result. try: if result is not None: - ticket = result[TicketQuery.Ticket.string] - except TypeError: - raise RuntimeError( - "If specified, 'result' parameter must be a TicketQuery.Ticket search result" - ) + _ticket = result[TicketQuery.Ticket.string] + except (TypeError, KeyError) as exc: + raise RuntimeError("If specified, 'result' parameter must be a TicketQuery.Ticket query result.") from exc + + # Process query result if given, and set object attributes from it. + if result is not None: + if _ticket != ticket != "": + raise RuntimeError("A ticket name was specified but does not match the query result.") + ticket = _ticket + for attr, value in TicketQuery.Ticket.__dict__.items(): + if value is TicketQuery.Ticket.string: + continue + if not attr.startswith("_"): + # backward compatibility with older schema versions + with contextlib.suppress(KeyError): + setattr(self, attr, result[value]) + self._ticket = ( ticket if ticket else self._generate(allow_punctuation=allow_punctuation) ) From 061c000004f9f227dccb079bbef38a53acbc3304 Mon Sep 17 00:00:00 2001 From: d-w-moore Date: Tue, 10 Mar 2026 09:21:30 -0400 Subject: [PATCH 2/2] [_3] test using 4.3.5 --- .../run-test-programs-single-node.yml | 2 +- .../run-test-suite-multiple-node.yml | 2 +- .../workflows/run-test-suite-single-node.yml | 2 +- .../setup-4.3.5.input | 28 +++++++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 test_harness/multiple_node/irods_catalog_provider_4/setup-4.3.5.input diff --git a/.github/workflows/run-test-programs-single-node.yml b/.github/workflows/run-test-programs-single-node.yml index 69473f1c7..03f35ad2a 100644 --- a/.github/workflows/run-test-programs-single-node.yml +++ b/.github/workflows/run-test-programs-single-node.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: python: ['3.9','3.13'] - irods_server: ['4.3.4','5.0.2'] + irods_server: ['4.3.5','5.0.2'] steps: - name: Checkout diff --git a/.github/workflows/run-test-suite-multiple-node.yml b/.github/workflows/run-test-suite-multiple-node.yml index 3a2d06fa9..3d2f20c2f 100644 --- a/.github/workflows/run-test-suite-multiple-node.yml +++ b/.github/workflows/run-test-suite-multiple-node.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: python: ['3.9','3.13'] - irods_server: ['4.3.4','5.0.2'] + irods_server: ['4.3.5','5.0.2'] steps: - name: Checkout diff --git a/.github/workflows/run-test-suite-single-node.yml b/.github/workflows/run-test-suite-single-node.yml index 7f3b88acc..d65dfa2ed 100644 --- a/.github/workflows/run-test-suite-single-node.yml +++ b/.github/workflows/run-test-suite-single-node.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: python: ['3.9','3.13'] - irods_server: ['4.3.4','5.0.2'] + irods_server: ['4.3.5','5.0.2'] steps: - name: Checkout diff --git a/test_harness/multiple_node/irods_catalog_provider_4/setup-4.3.5.input b/test_harness/multiple_node/irods_catalog_provider_4/setup-4.3.5.input new file mode 100644 index 000000000..d8c10deca --- /dev/null +++ b/test_harness/multiple_node/irods_catalog_provider_4/setup-4.3.5.input @@ -0,0 +1,28 @@ + + + + +irods-catalog +5432 +ICAT +irods +y +testpassword + +y +demoResc + +tempZone +1247 +20000 +20199 +1248 + +rods +y +TEMPORARY_ZONE_KEY +32_byte_server_negotiation_key__ +32_byte_server_control_plane_key +rods + +