Skip to content
Merged
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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,41 @@ and this project **only** adheres to the following _(as defined at [Semantic Ver
> - MINOR version when you add functionality in a backward compatible manner
> - PATCH version when you make backward compatible bug fixes

## [v3.3.0] - 2026-03-18

This release includes improvements for listing tickets, clean cancellation of parallel transfers, storage of binary data in AVUs, and more.

### Changed

- Migrate to pyproject.toml (#774, #783).

### Removed

- Remove Jenkins test framework (#778).

### Deprecated

- Deprecate `IRODS_VERSION` (#698).

### Fixed

- Fix ability to store arbitrary binary data in an AVU (#707).
- Add `__slots__` member to prevent configuration misfires (#708).
- Preserve state when chaining calls on metadata manager (#709).
- Fix segfault and hung threads when signals abort a parallel transfer (#722).
- Fix handling of username in GeneralAdmin API for proper removal of remote user home collection (#763).
- Use named loggers (#771).

### Added

- Add convenience functions for listing tickets (#120).
- Add automated testing via GitHub Actions (#502, #697).
- Add `_IRODS_VERSION` (#698).
- Add facilities for stopping parallel transfers in a clean manner (#722).
- Add configuration and GitHub Action workflows for code formatting and linting (#726).
- Allow MetadataManager options to be accessible as attributes (#795).
- Allow ticket instances to be populated using information from catalog queries (#801).

## [v3.2.0] - 2025-08-27

This release makes the library compatible with iRODS 5, adds support for the PAM Interactive authentication scheme, improves support for groupadmins, and adds new features for GenQuery1.
Expand Down
8 changes: 2 additions & 6 deletions irods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,7 @@ def derived_auth_filename(env_filename):
if not env_filename:
return ""
default_irods_authentication_file = os.path.expanduser("~/.irods/.irodsA")
return os.environ.get(
"IRODS_AUTHENTICATION_FILE", default_irods_authentication_file
)
return os.environ.get("IRODS_AUTHENTICATION_FILE", default_irods_authentication_file)


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -109,6 +107,4 @@ def get_settings_path():

# If the settings path variable is not set in the environment, a value of None is passed,
# and thus no settings file is auto-loaded.
client_configuration.autoload(
_file_to_load=os.environ.get(settings_path_environment_variable)
)
client_configuration.autoload(_file_to_load=os.environ.get(settings_path_environment_variable))
23 changes: 5 additions & 18 deletions irods/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ def items(self):


class iRODSAccess(metaclass=_Access_LookupMeta):

@classmethod
def to_int(cls, key):
return cls.codes[key]
Expand Down Expand Up @@ -74,9 +73,7 @@ def to_string(cls, key):
)
)

strings = collections.OrderedDict(
(number, string) for string, number in codes.items()
)
strings = collections.OrderedDict((number, string) for string, number in codes.items())

def __init__(self, access_name, path, user_name="", user_zone="", user_type=None):
self.access_name = access_name
Expand All @@ -103,9 +100,7 @@ def __eq__(self, other):
)

def __hash__(self):
return hash(
(self.access_name, iRODSPath(self.path), self.user_name, self.user_zone)
)
return hash((self.access_name, iRODSPath(self.path), self.user_name, self.user_zone))

def copy(self, decanonicalize=False):
other = copy.deepcopy(self)
Expand All @@ -116,19 +111,13 @@ def copy(self, decanonicalize=False):
"modify object": "write",
"modify_object": "write",
}.get(self.access_name)
other.access_name = (
replacement_string
if replacement_string is not None
else self.access_name
)
other.access_name = replacement_string if replacement_string is not None else self.access_name
return other

def __repr__(self):
object_dict = vars(self)
access_name = self.access_name.replace(" ", "_")
user_type_hint = (
"({user_type})" if object_dict.get("user_type") is not None else ""
).format(**object_dict)
user_type_hint = ("({user_type})" if object_dict.get("user_type") is not None else "").format(**object_dict)
return f"<iRODSAccess {access_name} {self.path} {self.user_name}{user_type_hint} {self.user_zone}>"


Expand All @@ -138,6 +127,4 @@ class _iRODSAccess_pre_4_3_0(iRODSAccess):
for key, value in iRODSAccess.codes.items()
if key in ("own", "write", "modify_object", "read", "read_object", "null")
)
strings = collections.OrderedDict(
(number, string) for string, number in codes.items()
)
strings = collections.OrderedDict((number, string) for string, number in codes.items())
3 changes: 1 addition & 2 deletions irods/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@


class iRODSAccount:

@property
def derived_auth_file(self):
return derived_auth_filename(self.env_file)
Expand All @@ -19,7 +18,7 @@ def __init__(
server_dn=None,
client_zone=None,
env_file="",
**kwargs
**kwargs,
):

# Allowed overrides when cloning sessions. (Currently hostname only.)
Expand Down
17 changes: 4 additions & 13 deletions irods/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,7 @@ def throw_if_request_message_is_missing_key(request, required_keys):

def _auth_api_request(conn, data):
message_body = JSON_Message(data, conn.server_version)
message = iRODSMessage(
"RODS_API_REQ", msg=message_body, int_info=api_number["AUTHENTICATION_APN"]
)
message = iRODSMessage("RODS_API_REQ", msg=message_body, int_info=api_number["AUTHENTICATION_APN"])
conn.send(message)
response = conn.recv()
return response.get_json_encoded_struct()
Expand All @@ -162,7 +160,6 @@ def _auth_api_request(conn, data):


class authentication_base:

def __init__(self, connection, scheme):
self.conn = connection
self.loggedIn = 0
Expand All @@ -181,9 +178,7 @@ def call(self, next_operation, request):
_logger.debug("resp = %r", resp)
return resp

def authenticate_client(
self, next_operation="auth_client_start", initial_request=()
):
def authenticate_client(self, next_operation="auth_client_start", initial_request=()):
if not isinstance(initial_request, dict):
initial_request = dict(initial_request)

Expand All @@ -196,13 +191,9 @@ def authenticate_client(
break
next_operation = resp.get(__NEXT_OPERATION__)
if next_operation is None:
raise ClientAuthError(
"next_operation key missing; cannot determine next operation"
)
raise ClientAuthError("next_operation key missing; cannot determine next operation")
if next_operation in (__FLOW_COMPLETE__, ""):
raise ClientAuthError(
f"authentication flow stopped without success: scheme = {self.scheme}"
)
raise ClientAuthError(f"authentication flow stopped without success: scheme = {self.scheme}")
to_send = resp

_logger.debug("fully authenticated")
20 changes: 5 additions & 15 deletions irods/auth/native.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ class _native_ClientAuthState(authentication_base):
def auth_client_start(self, request):
resp = request.copy()
# user_name and zone_name keys injected by authenticate_client() method
resp[__NEXT_OPERATION__] = (
self.AUTH_CLIENT_AUTH_REQUEST
) # native_auth_client_request
resp[__NEXT_OPERATION__] = self.AUTH_CLIENT_AUTH_REQUEST # native_auth_client_request
return resp

def native_auth_client_request(self, request):
Expand All @@ -76,9 +74,7 @@ def native_auth_client_request(self, request):
return resp

def native_auth_establish_context(self, request):
throw_if_request_message_is_missing_key(
request, ["user_name", "zone_name", "request_result"]
)
throw_if_request_message_is_missing_key(request, ["user_name", "zone_name", "request_result"])
request = request.copy()

password = ""
Expand All @@ -91,13 +87,9 @@ def native_auth_establish_context(self, request):
password = self.conn.account.password or ""

challenge = request["request_result"].encode("utf-8")
self.conn._client_signature = "".join(
"{:02x}".format(c) for c in challenge[:16]
)
self.conn._client_signature = "".join("{:02x}".format(c) for c in challenge[:16])

padded_pwd = struct.pack(
"%ds" % MAX_PASSWORD_LENGTH, password.encode("utf-8").strip()
)
padded_pwd = struct.pack("%ds" % MAX_PASSWORD_LENGTH, password.encode("utf-8").strip())

m = hashlib.md5()
m.update(challenge)
Expand All @@ -113,9 +105,7 @@ def native_auth_establish_context(self, request):
return request

def native_auth_client_response(self, request):
throw_if_request_message_is_missing_key(
request, ["user_name", "zone_name", "digest"]
)
throw_if_request_message_is_missing_key(request, ["user_name", "zone_name", "digest"])

server_req = request.copy()
server_req[__NEXT_OPERATION__] = self.AUTH_AGENT_AUTH_RESPONSE
Expand Down
16 changes: 8 additions & 8 deletions irods/auth/pam_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
throw_if_request_message_is_missing_key,
AuthStorage,
STORE_PASSWORD_IN_MEMORY,
CLIENT_GET_REQUEST_RESULT
CLIENT_GET_REQUEST_RESULT,
)
from .native import _authenticate_native

Expand All @@ -17,7 +17,7 @@
import logging
import sys

# Constants defining the states and operations for the pam_interactive authentication flow
# Constants defining the states and operations for the pam_interactive authentication flow
AUTH_CLIENT_AUTH_REQUEST = "pam_auth_client_request"
AUTH_CLIENT_AUTH_RESPONSE = "pam_auth_response"
PERFORM_RUNNING = "running"
Expand All @@ -38,16 +38,16 @@

_logger = logging.getLogger(__name__)


def login(conn, **extra_opt):
"""The entry point for the pam_interactive authentication scheme."""

# The AuthStorage object holds the token generated by the server for the native auth step
depot = AuthStorage.create_temp_pw_storage(conn)

auth_client_object = _pam_interactive_ClientAuthState(conn, depot, scheme=PAM_INTERACTIVE_SCHEME)
auth_client_object.authenticate_client(
initial_request=extra_opt
)
auth_client_object.authenticate_client(initial_request=extra_opt)


class _pam_interactive_ClientAuthState(authentication_base):
def __init__(self, conn, depot, *_, **_kw):
Expand All @@ -64,7 +64,7 @@ def auth_client_start(self, request):
# to recall previous inputs through JSON pointers. "pdirty" flags if pstate has changed and needs syncing.
# The server side implementation can be found here: https://github.com/irods/irods_auth_plugin_pam_interactive
# The plugin is built on the authentication framework described here:
# https://github.com/irods-contrib/irods_working_group_authentication/tree/e83e84df8ea4a732e5de894fb28aae281c3b3d29/development
# https://github.com/irods-contrib/irods_working_group_authentication/tree/e83e84df8ea4a732e5de894fb28aae281c3b3d29/development

resp["pstate"] = resp.get("pstate", {})
resp["pdirty"] = resp.get("pdirty", False)
Expand Down Expand Up @@ -123,7 +123,7 @@ def _patch_state(self, req):

# If the patch operation is an add or replace without a value, use the response value (following json patch RFC)
for op in patch_ops:
if op.get("op") in ["add", "replace"] and "value" not in op:
if op.get("op") in ["add", "replace"] and "value" not in op:
op["value"] = resp

req["pstate"] = jsonpatch.apply_patch(req.get("pstate", {}), patch_ops)
Expand Down Expand Up @@ -266,4 +266,4 @@ def timeout(self, request):
return self._auth_failure(request, "Authentication timed out.")

def not_authenticated(self, request):
return self._auth_failure(request, "Authentication failed possibly due to incorrect credentials.")
return self._auth_failure(request, "Authentication failed possibly due to incorrect credentials.")
21 changes: 5 additions & 16 deletions irods/auth/pam_password.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,16 @@ def _authenticate_pam_password(conn, req):

_ = AuthStorage.create_temp_pw_storage(conn)

_pam_password_ClientAuthState(conn, scheme=_scheme).authenticate_client(
initial_request=req
)
_pam_password_ClientAuthState(conn, scheme=_scheme).authenticate_client(initial_request=req)

_logger.debug("----------- %s (end)", _scheme)


def _get_pam_password_from_stdin(
file_like_object=None, prompt="Enter your current PAM password: "
):
def _get_pam_password_from_stdin(file_like_object=None, prompt="Enter your current PAM password: "):
try:
if file_like_object:
if not getattr(file_like_object, "readline", None):
msg = (
"The file_like_object, if provided, must have a 'readline' method."
)
msg = "The file_like_object, if provided, must have a 'readline' method."
raise RuntimeError(msg)
sys.stdin = file_like_object
if sys.stdin.isatty():
Expand All @@ -84,7 +78,6 @@ def _get_pam_password_from_stdin(


class _pam_password_ClientAuthState(authentication_base):

# Client define
AUTH_CLIENT_AUTH_REQUEST = "pam_password_auth_client_request"

Expand All @@ -100,9 +93,7 @@ def auth_client_start(self, request):

# This list reference is popped and cached for the purpose of returning the request_result value
# to the caller upon request.
self._list_for_request_result_return = request.pop(
CLIENT_GET_REQUEST_RESULT, False
)
self._list_for_request_result_return = request.pop(CLIENT_GET_REQUEST_RESULT, False)

ensure_ssl = request.pop(ENSURE_SSL_IS_ACTIVE, None)
if ensure_ssl is not None:
Expand All @@ -121,9 +112,7 @@ def auth_client_start(self, request):
if isinstance(password_input_obj, (int, bool)):
password_input_obj = None
# Like with the C++ plugin, we offer the user a chance to enter a password.
resp[AUTH_PASSWORD_KEY] = _get_pam_password_from_stdin(
file_like_object=password_input_obj
)
resp[AUTH_PASSWORD_KEY] = _get_pam_password_from_stdin(file_like_object=password_input_obj)
else:
# Password from .irodsA in environment.
if self.conn.account._auth_file:
Expand Down
Loading
Loading