Skip to content
6 changes: 2 additions & 4 deletions irods/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,10 +242,8 @@ class Ticket(Model):
write_file_limit = Column(Integer, "TICKET_WRITE_FILE_LIMIT", 2212)
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
65 changes: 62 additions & 3 deletions irods/test/ticket_test.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
#! /usr/bin/env python

import calendar
import datetime
import logging
import os
import sys
import unittest
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.ticket import enumerate_tickets, Ticket
from irods.models import TicketQuery, DataObject, Collection

Check failure on line 17 in irods/test/ticket_test.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff I001

I001: Import block is un-sorted or un-formatted [isort:unsorted-imports]


logger = logging.getLogger(__name__)


# 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.
# Counterexamples within this module shall be obvious as they are instantiated by
Expand All @@ -29,6 +34,17 @@
)


def delete_tickets(session, dry_run = False):
for res in session.query(TicketQuery.Ticket):
t = Ticket(session, result=res)
if dry_run in (False, None):
t.delete(**{kw.ADMIN_KW: ""})
elif isinstance(dry_run, list):
dry_run.append(t)
else:
logger.info('Found ticket: %s',t.string)

Check failure on line 45 in irods/test/ticket_test.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting


def delete_my_tickets(session):
my_userid = session.users.get(session.username).id
my_tickets = session.query(TicketQuery.Ticket).filter(
Expand Down Expand Up @@ -358,6 +374,31 @@
os.unlink(file_.name)
alice.cleanup()

def test_new_attributes_in_tickets__issue_801(self):
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's explicitly mention the attributes being tested here (i.e. create_time and modify_time). These will not be "new" for very long.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fwiw, id is also being tested!

Copy link
Contributor

Choose a reason for hiding this comment

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

Sure, whatever attributes are relevant. Or, if you prefer, an absolute moment in time: "attributes_unavailable_before_irods_4_3" or something like that.

# Specifically we are testing that 'modify_time' and 'create_time' attributes function as expected,
# and that other attributes such as 'id' are also present.
admin_ticket_for_bob = None

if (admin:=helpers.make_session()).server_version < (4, 3, 0):
self.skipTest('"create_time" and "modify_time" not supported for Ticket')
Comment on lines +382 to +383
Copy link
Contributor

Choose a reason for hiding this comment

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

This check isn't necessary since we no longer support anything earlier than 4.3.0.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ok. we sure of that? I've heard concern from some on our team, in the not-so-distant past, that PRC may no longer be compatible with iRODS 3.0.

Copy link
Contributor

Choose a reason for hiding this comment

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

ok. we sure of that?

Yes. The 4.2 series is EOL which automatically makes iRODS 3 EOL.

For users running 4.2, they have the ability to install earlier versions of the PRC and/or compile Python 3 if PRC 3 is needed.

Of course, we don't make any guarantees about whether PRC 3 works with 4.2 deployments because that's not part of our testing.


try:
with self.login(self.bob) as bob:
bobs_ticket = Ticket(bob)
bobs_ticket.issue('write', helpers.home_collection(bob))
time.sleep(2)
bobs_ticket.modify('add', 'user', admin.username)
bobs_ticket = Ticket(bob, result=enumerate_tickets(bob, raw=True), ticket=bobs_ticket.string)
self.assertGreaterEqual(
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be assertGreater? Won't this test pass even if the modify_time did not change?

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 it's correct. We're comparing modify_time to a timestamp 1 second or more later than the create_time, which I think is reasonable from the sleep(2) and the user add. The only way it will pass is if the modify time falls in that window.

If modify_time did not change, then presumably it will be closer to create_time than 1 second in the future, so the test will fail.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, I see... so... we're comparing modify_time to create_time-plus-1. So it could be 1 or more seconds different. In that case... could we not have this?

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

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

Check failure on line 394 in irods/test/ticket_test.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting
)

admin_ticket_for_bob = Ticket(admin, result=enumerate_tickets(admin, raw=True), ticket=bobs_ticket.string)
self.assertEqual(admin_ticket_for_bob.id, bobs_ticket.id)
finally:
if admin_ticket_for_bob:
admin_ticket_for_bob.delete(**{kw.ADMIN_KW:''})

class TestTicketOps(unittest.TestCase):

Expand Down Expand Up @@ -455,7 +496,25 @@
def test_coll_ticket_write(self):
self._ticket_write_helper(obj_type="coll")


def test_enumerate_tickets__issue_120(self):

ses = self.sess

# t first assigned as a "utility" Ticket object
t = Ticket(ses).issue('read', helpers.home_collection(ses))

# This time, t receives attributes from an internal GenQuery result.
t = Ticket(
ses,
result=enumerate_tickets(ses, raw=True),
ticket=t.string
)

Check failure on line 512 in irods/test/ticket_test.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting

# Check an id attribute is present and listed in the results from list_tickets
self.assertIn(t.id, (_.id for _ in enumerate_tickets(ses)))


if __name__ == "__main__":
# let the tests find the parent irods lib
sys.path.insert(0, os.path.abspath("../.."))
Expand Down
82 changes: 76 additions & 6 deletions irods/ticket.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from irods.api_number import api_number
from irods.message import iRODSMessage, TicketAdminRequest
from irods.models import TicketQuery
from irods.column import Like, Column

Check failure on line 4 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff F401

F401: `irods.column.Like` imported but unused [Pyflakes:unused-import]

from collections.abc import Mapping, Sequence

Check failure on line 6 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff F401

F401: `collections.abc.Sequence` imported but unused [Pyflakes:unused-import]
from typing import Any, Iterable, Optional, Type, Union

Check failure on line 7 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff UP035

UP035: `typing.Type` is deprecated, use `type` instead [pyupgrade:deprecated-import]

Check failure on line 7 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff UP035

UP035: Import from `collections.abc` instead: `Iterable` [pyupgrade:deprecated-import]

import random
import string
Expand All @@ -27,20 +31,86 @@
except ValueError:
raise # final try at conversion, so a failure is an error

class default_ticket_query_factory:

Check failure on line 34 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D101

D101: Missing docstring in public class [pydocstyle:undocumented-public-class]

Check failure on line 34 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff N801

N801: Class name `default_ticket_query_factory` should use CapWords convention [pep8-naming:invalid-class-name]
_callable = staticmethod(lambda session: session.query(TicketQuery.Ticket))
def __call__(self, session):

Check failure on line 36 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D102

D102: Missing docstring in public method [pydocstyle:undocumented-public-method]
return self._callable(session)

def enumerate_tickets(session, *, query_factory=default_ticket_query_factory, raw=False):
"""
Enumerates (via GenQuery1) all tickets visible by, or owned by, the current user.
Copy link
Contributor

@korydraughn korydraughn Mar 5, 2026

Choose a reason for hiding this comment

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

GenQuery1 is an implementation detail. It's unlikely that anyone needs to know how the information is gathered.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Noted. Will change if desired. However, not mentioning it could confuse beginners, unless some work is done on the README. (Note for the "future me"? Should I explain what specific queries are incompatible with general queries? Or that Genquery2 will not suffice for this purpose until/unless the entire library is basically recast to be based on GenQuery2.)

Copy link
Contributor

Choose a reason for hiding this comment

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

However, not mentioning it could confuse beginners, unless some work is done on the README.

Please say more.

Copy link
Collaborator Author

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

Choose a reason for hiding this comment

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

Well, finally I decided I didn't need the mention.

But, and I am asking because I'm curious if we are all tuned in to the inevitability that a removal of GenQuery1 would break the PRC at its foundation? It would need a re-design from the bottom of the foundation up.

This raises the point: Do we plan on deprecating removing GenQuery1? Maybe around iRODS 7 or so? (Based on what I believe I've heard.)

Copy link
Contributor

Choose a reason for hiding this comment

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

The plan is to eventually remove GenQuery1 in the server, which would break all client code. Of course that might not be a reality for a very long time, e.g. iRODS 10, because we'd need full confidence that GenQuery2 covers everything.

I don't think a grand redesign of the PRC will be required when that time comes because people write iRODS code in terms of function calls. It would mostly only require changing the internals of each call.

There's nothing stopping us from offering a cleaner/smaller library for the python space today. Again, we'd have to continue supporting the existing implementation for a while since users have built important software on top of the PRC. Of course, we could offer automated tools for converting between the two libraries, but that requires a lot of work.


Args:
session: An iRODSSession object for use in the query.
query_factory: A class capable of generating a generic query or other iterable
over TicketQuery.Ticket row results.
raw: If false, transform each row returned into a Ticket object; else return
the result rows unaltered.

Returns:
An iterator over a range of ticket objects.
"""

Check failure on line 52 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-check

Ruff D401

D401: First line of docstring should be in imperative mood: "Enumerates (via GenQuery1) all tickets visible by, or owned by, the current user." [pydocstyle:non-imperative-mood]
query = query_factory()(session)

if raw:
yield from query
else:
yield from (Ticket(session, result=row) for row in query)

Check failure on line 58 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting

_column_lookup = dict[Type[Column], Any]

class Ticket:
def __init__(self, session, ticket="", result=None, allow_punctuation=False):

def __init__(self,
session,
ticket="",
result: Optional[Union[_column_lookup, Iterable[_column_lookup]]] =None, # Optional (vs. '|') is Python 3.9 syntax
allow_punctuation=False):

Check failure on line 68 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting
Comment on lines +64 to +68
Copy link
Contributor

@korydraughn korydraughn Mar 6, 2026

Choose a reason for hiding this comment

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

I'm finding this function hard to reason about, primarily in regard to ticket and result.

  • What happens when ticket and result are set to inappropriate values?
  • Which cases are valid vs invalid?

Maybe this constructor has too many responsibilities and splitting the logic across a few helper functions is all that's needed?

For example:

def __init__(self, session, ticket="", result:... = None, allow_punctuation=False):
    if case_0_is_satisfied:
        self._init_with_ticket(session, ticket)
    elif case_1_is_satisfied:
        self._init_with_ticket_and_result(session, ticket, result)
    elif case_2_is_satisfied:
        self._init_with_result(session, result)

    raise Exception("Invalid args ...")     

Splitting up the code into smaller chunks let's you assume certain things are true moving forward.


self._session = session

try:
if result is not None:
if isinstance(result, Mapping):
if (single_string:=result.get(TicketQuery.Ticket.string, '')):
if ticket and (ticket != single_string):
raise RuntimeError(
f"The specified result contained a ticket string mismatched to the provided identifier ({ticket = })"
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider rephrasing this statement. For example:

f"No entry found in result argument matching ticket string ({ticket=})"

)

elif hasattr(result, '__iter__'):
if ticket:
result = [row for row in result if row[TicketQuery.Ticket.string] == ticket][:1]
Comment on lines +81 to +82
Copy link
Contributor

Choose a reason for hiding this comment

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

Can this fail if the ticket string doesn't match a row?


if not result:
result = None
else:
result = next(iter(result)) # result[0]

Check failure on line 87 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting

if result:
ticket = result[TicketQuery.Ticket.string]
for attr, value in TicketQuery.Ticket.__dict__.items():
if value is TicketQuery.Ticket.string: continue

Check failure on line 92 in irods/ticket.py

View workflow job for this annotation

GitHub Actions / ruff-lint / ruff-format

Ruff format

Improper formatting
if not attr.startswith("_"):
try:
setattr(self, attr, result[value])
except KeyError:
# backward compatibility with older schema versions
pass

self._ticket = ticket

except TypeError:
raise RuntimeError(
"If specified, 'result' parameter must be a TicketQuery.Ticket search result"
"If specified, 'result' parameter must be a TicketQuery.Ticket search result or iterable of same"
)
self._ticket = (
ticket if ticket else self._generate(allow_punctuation=allow_punctuation)
)

except IndexError:
raise RuntimeError(
"If both result and string are specified, at least one 'result' must match the ticket string"
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the user be able to specify both? Does this lead to deterministic behavior?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In a minor release I'd rather not change it, but I know it's confusing.
Keeping it this way lets the PRC specify a ticket string on creation, like iticket:

Ticket(ses, ticket='my_ticket_name').issue('read',object_path)

It is admittedly confusing. Ticket objects exist in raw and cooked forms. The raw is for setup/creation, the cooked is to represent row results from a query. We can examine.

Copy link
Contributor

Choose a reason for hiding this comment

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

Wait... if result and ticket are provided, isn't ticket used to filter result to the correct entry?

Copy link
Collaborator Author

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

Choose a reason for hiding this comment

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

Right. If result is the default of None however, as opposed to empty list [ ] , you'll always arrive at a raw object. This default is reverse compatible with old applications

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Wait... if result and ticket are provided, isn't ticket used to filter result to the correct entry?

Yes. if it's a list of DB entries, ticket is used to filter. If there's only a single result passed in, that's used to produce a cooked object.

Copy link
Contributor

@korydraughn korydraughn Mar 5, 2026

Choose a reason for hiding this comment

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

Don't you get a "cooked" object after filtering a list containing multiple items/rows too?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Don't you get a "cooked" object after filtering a list containing multiple items/rows too?

Yes, both are true. If the ticket string is given and there is a matching row, we get an object with the attributes filled in, ie the cooked object.

Copy link
Contributor

Choose a reason for hiding this comment

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

What happens if no row in result matches the ticket string?

I tried reading the implementation, but it wasn't clear to me if an error is raised or not. It appears you get an object containing the ticket string and that's it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Well , my intent is if you specify a ticket string and it isn't matched among the results, an error is raised. I'll make sure it's the case. I think, too, that is the logical expectation.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I agree. That makes the most sense to me.

)

if not self._ticket:
self._ticket = self._generate(allow_punctuation=allow_punctuation)

@property
def session(self):
Expand Down
Loading