Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-test-programs-single-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/run-test-suite-multiple-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/run-test-suite-single-node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1850,6 +1850,65 @@ 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`.

Removing orphan tickets
-----------------------

It is also possible that a query could turn up "orphan" tickets: those which are no longer
assigned to any valid user. These cannot be selected in the query by negating the filter
shown in the example just shown; so, another way is needed.

To single out and remove tickets not corresponding to any extant user account, a rods admin
could do something like the following:

```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
--------------------------------------------------

Expand Down
5 changes: 2 additions & 3 deletions irods/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 49 additions & 9 deletions irods/test/ticket_test.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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}"
Expand All @@ -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
Expand Down Expand Up @@ -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)
)
Comment on lines +376 to +378
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this equivalent to:

self.assertGreater(bobs_ticket.modify_time, bobs_ticket.create_time)

Copy link
Collaborator Author

@d-w-moore d-w-moore Mar 16, 2026

Choose a reason for hiding this comment

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

I suppose that depends on whether fractional seconds are stored by the server and/or are relayed to clients?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think they are likely the same, if the answer is "no" to both.

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay. Up to you if we should change it. I find the assertGreater to be clearer, but above all the test should be correct.

Copy link
Contributor

Choose a reason for hiding this comment

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

I suppose that depends on whether fractional seconds are stored by the server and/or are relayed to clients?

Fractional seconds are not stored in the catalog for tickets.

Copy link
Collaborator Author

@d-w-moore d-w-moore Mar 16, 2026

Choose a reason for hiding this comment

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

@alanking It makes sense now; I remember that fractional seconds are not a thing in iRODS DB rows.... doh.

But I might choose to increase the robustness of the test by a second, in which we'd want:

self.assertGreater(bobs_ticket.modify_time, bobs_ticket.create_time + datetime.timedelta(seconds=1) )

Copy link
Collaborator Author

@d-w-moore d-w-moore Mar 16, 2026

Choose a reason for hiding this comment

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

The test would also have to be updated if for some reason in the future, fractional seconds in the DB became a thing, and say the resolution or latency from the server side's OS were 1/20th of a second for example ( as was true in some early PCs, I think.) Then you'd want something more like the way it's written now.

Also, what if the creation time is different by an iota from modify time? Then the server records two different numbers, right? Is it ever possible the creation time will be greater than the modify time by a tiny iota once the ticket is created , resulting in rounding errors that cause the two to be off by a second? You see, I am unreasonably careful these days because of the recent past: 3c3ab0b

finally:
if bobs_ticket:
bobs_ticket.delete()


class TestTicketOps(unittest.TestCase):

Expand Down Expand Up @@ -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
Expand Down
74 changes: 59 additions & 15 deletions irods/ticket.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from irods.api_number import api_number
from irods.message import iRODSMessage, TicketAdminRequest
from irods.models import TicketQuery

import calendar

Check failure on line 1 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D100

D100: Missing docstring in public module [pydocstyle:undocumented-public-module]
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):
Expand All @@ -28,16 +27,61 @@
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.
"""

Check failure on line 62 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D205

D205: 1 blank line required between summary line and description [pydocstyle:missing-blank-line-after-summary]
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)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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


Loading