Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-rt for openSUSE:Factory 
checked in at 2026-01-08 15:28:50
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-rt (Old)
 and      /work/SRC/openSUSE:Factory/.python-rt.new.1928 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-rt"

Thu Jan  8 15:28:50 2026 rev:25 rq:1325936 version:3.4.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-rt/python-rt.changes      2025-09-08 
09:57:27.214217015 +0200
+++ /work/SRC/openSUSE:Factory/.python-rt.new.1928/python-rt.changes    
2026-01-08 15:29:53.651239189 +0100
@@ -1,0 +2,24 @@
+Thu Jan  1 10:24:10 UTC 2026 - Sebastian Wagner <[email protected]>
+
+- Update to version v3.4.0:
+ - Added
+ - Added functionality for some of the asset endpoints (get, create, edit, 
search, get history)
+ - Added functionality for the get catalog endpoint
+- Update to version v3.3.9:
+ - Fixes
+ - In debug mode, where content may be dumped, said content may not decode 
correctly if it is not utf-8. Ignore errors as we don't care about that in 
debug mode anyways (fixes #113)
+- Update to version v3.3.8:
+ - Added
+ - Allow for specifying a custom RT JSON filter when querying for attachments 
for a ticket (#110). This solved the issue with not returning attachment IDs in
+     case an attachment file name is empty as the default query explicitely 
excludes those.
+ - Changes
+ - Remove unused noqa directives
+ - Do not use len() in asset when no comparison is being done
+ - Add quotes to type expression in `typing.cast()`
+- Update to version v3.3.7:
+ - Changes
+ - Use RT v6 based docker image for tests
+ - Fixes
+ - Fix optional return types (#111)
+
+-------------------------------------------------------------------

Old:
----
  rt-3.3.6.tar.gz

New:
----
  rt-3.4.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-rt.spec ++++++
--- /var/tmp/diff_new_pack.NuiloT/_old  2026-01-08 15:29:54.143259624 +0100
+++ /var/tmp/diff_new_pack.NuiloT/_new  2026-01-08 15:29:54.143259624 +0100
@@ -1,7 +1,7 @@
 #
 # spec file for package python-rt
 #
-# Copyright (c) 2025 SUSE LLC and contributors
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-rt
-Version:        3.3.6
+Version:        3.4.0
 Release:        0
 Summary:        Python interface to Request Tracker API
 License:        GPL-3.0-only

++++++ rt-3.3.6.tar.gz -> rt-3.4.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/.github/workflows/pythonpublish.yml 
new/python-rt-3.4.0/.github/workflows/pythonpublish.yml
--- old/python-rt-3.3.6/.github/workflows/pythonpublish.yml     2025-04-24 
15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/.github/workflows/pythonpublish.yml     2025-11-28 
08:11:12.000000000 +0100
@@ -7,10 +7,12 @@
 
 jobs:
   release-build:
+    permissions:
+      contents: read
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-python@v5
+      - uses: actions/checkout@v5
+      - uses: actions/setup-python@v6
         with:
           python-version: '3.x'
       - name: Install dependencies and build wheel
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/.github/workflows/test_lint.yml 
new/python-rt-3.4.0/.github/workflows/test_lint.yml
--- old/python-rt-3.3.6/.github/workflows/test_lint.yml 2025-04-24 
15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/.github/workflows/test_lint.yml 2025-11-28 
08:11:12.000000000 +0100
@@ -1,4 +1,6 @@
 name: Run tests
+permissions:
+  contents: read
 
 on:
   push:
@@ -14,7 +16,7 @@
 
     services:
       rt:
-        image: netsandbox/request-tracker:5.0
+        image: netsandbox/request-tracker:6.0
         ports:
           - 8080:8080
         env:
@@ -25,9 +27,9 @@
         python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
 
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@v6
         with:
           python-version: ${{ matrix.python-version }}
 
@@ -47,15 +49,15 @@
 
     services:
       rt:
-        image: netsandbox/request-tracker:5.0
+        image: netsandbox/request-tracker:6.0
         ports:
           - 8080:8080
         env:
           RT_WEB_PORT: 8080
 
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-python@v5
+      - uses: actions/checkout@v5
+      - uses: actions/setup-python@v6
         with:
           python-version: '3.11'
 
@@ -85,8 +87,8 @@
   lint_python:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
-      - uses: actions/setup-python@v5
+      - uses: actions/checkout@v5
+      - uses: actions/setup-python@v6
         with:
           python-version: '3.11'
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/.readthedocs.yaml 
new/python-rt-3.4.0/.readthedocs.yaml
--- old/python-rt-3.3.6/.readthedocs.yaml       2025-04-24 15:35:48.000000000 
+0200
+++ new/python-rt-3.4.0/.readthedocs.yaml       2025-11-28 08:11:12.000000000 
+0100
@@ -7,13 +7,18 @@
 
 # Set the version of Python and other tools you might need
 build:
-  os: ubuntu-20.04
+  os: ubuntu-lts-latest
   tools:
     python: "3"
-    # You can also specify other tool versions:
-    # nodejs: "16"
-    # rust: "1.55"
-    # golang: "1.17"
+  jobs:
+    pre_create_environment:
+      - asdf plugin add uv
+      - asdf install uv latest
+      - asdf global uv latest
+    create_environment:
+      - uv venv "${READTHEDOCS_VIRTUALENV_PATH}"
+    install:
+      - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync 
--group docs
 
 # Build documentation in the docs/ directory with Sphinx
 sphinx:
@@ -22,11 +27,3 @@
 # If using Sphinx, optionally build your docs in additional formats such as PDF
 # formats:
 #    - pdf
-
-# Optionally declare the Python requirements required to build your docs
-python:
-  install:
-    - method: pip
-      path: .
-      extra_requirements:
-        - docs
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/CHANGELOG.md 
new/python-rt-3.4.0/CHANGELOG.md
--- old/python-rt-3.3.6/CHANGELOG.md    2025-04-24 15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/CHANGELOG.md    2025-11-28 08:11:12.000000000 +0100
@@ -3,44 +3,68 @@
 
 This project adheres to [Semantic 
Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [v3.4.0], 2025-11-21
+### Added
+- Added functionality for some of the asset endpoints (get, create, edit, 
search, get history)
+- Added functionality for the get catalog endpoint
+
+## [v3.3.9], 2025-10-01
+### Fixes
+- In debug mode, where content may be dumped, said content may not decode 
correctly if it is not utf-8. Ignore errors as we don't care about that in 
debug mode anyways (fixes #113)
+
+## [v3.3.8], 2025-09-25
+### Added
+- Allow for specifying a custom RT JSON filter when querying for attachments 
for a ticket (#110). This solved the issue with not returning attachment IDs in
+    case an attachment file name is empty as the default query explicitely 
excludes those.
+### Changes
+- Remove unused noqa directives
+- Do not use len() in asset when no comparison is being done
+- Add quotes to type expression in `typing.cast()`
+
+## [v3.3.7], 2025-09-24
+### Changes
+- Use RT v6 based docker image for tests
+### Fixes
+- Fix optional return types (#111)
+
 ## [v3.3.6], 2025-04-24
-## Fixes
+### Fixes
 - Catch *TransportError* from httpx and re-raise as *ConnectionError* so that 
httpx transport error exceptions do not leak (fixes #109).
 
 ## [v3.3.5], 2025-04-18
-## Fixes
+### Fixes
 - There was still a comparison issue, fix bad date comparison (fixes #107)
 
 ## [v3.3.4], 2025-03-03
-## Fixes
+### Fixes
 - Fix bad date comparison (fixes #107)
 
 ## [v3.3.3], 2024-12-02
-## Changes
+### Changes
 - Starting with version 0.28.0 of httpx, *verify* should be either a bool or 
an *SSL Context*.
 
 ## [v3.3.2], 2024-12-02
-## Fixes
+### Fixes
 - Replace the removed httpx parameter of *proxies* by *proxy* (fixes #102)
 - Pin dependencies to supported relative upstream versions.
 - Remove the now obsolete *setup.py*.
 
 ## [v3.3.1], 2024-11-14
-## Fixes
+### Fixes
 - Fix str(bytes) warning (*BytesWarning: str() on a bytes instance*) (#1074)
 
-## Changes
+### Changes
 - Set included files for ruff
 - Switch to hatchling
 - Set ignores for tests files
 - Ignore uv.lock
 
 ## [v3.3.0], 2024-10-04
-## Removed
+### Removed
 - Remove support for now EoL Python 3.8.
 
 ## [v3.2.0], 2024-09-06
-## Added
+### Added
 - Added option for custom list of fields to be populated for search 
"query_format" param to avoid unnecessary round trips to get fields like Told, 
Starts, Resolved, etc by returning the required fields during search. (see #97 
@nerdfirefighter)
 
 ## [v3.1.4], 2024-02-16
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/MANIFEST.in 
new/python-rt-3.4.0/MANIFEST.in
--- old/python-rt-3.3.6/MANIFEST.in     2025-04-24 15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/MANIFEST.in     2025-11-28 08:11:12.000000000 +0100
@@ -6,6 +6,5 @@
 recursive-include tests *
 
 recursive-exclude .github *
-exclude .codebeatignore
 exclude .gitignore
 exclude .readthedocs.yaml
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/README.rst 
new/python-rt-3.4.0/README.rst
--- old/python-rt-3.3.6/README.rst      2025-04-24 15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/README.rst      2025-11-28 08:11:12.000000000 +0100
@@ -1,6 +1,3 @@
-.. image:: https://codebeat.co/badges/a52cfe15-b824-435b-a594-4bf2be2fb06f
-    :target: https://codebeat.co/projects/github-com-python-rt-python-rt-master
-    :alt: codebeat badge
 .. image:: 
https://github.com/python-rt/python-rt/actions/workflows/test_lint.yml/badge.svg
     :target: 
https://github.com/python-rt/python-rt/actions/workflows/test_lint.yml
     :alt: tests
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/doc/conf.py 
new/python-rt-3.4.0/doc/conf.py
--- old/python-rt-3.3.6/doc/conf.py     2025-04-24 15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/doc/conf.py     2025-11-28 08:11:12.000000000 +0100
@@ -13,7 +13,7 @@
 
 import os
 import sys
-from pkg_resources import get_distribution
+import importlib.metadata
 
 sys.path.insert(0, os.path.abspath('..'))
 
@@ -59,9 +59,12 @@
 # built documents.
 #
 # The full version, including alpha/beta/rc tags.
-release = get_distribution('rt').version
-# The short X.Y version.
-version = '.'.join(release.split('.')[:2])
+try:
+    release = importlib.metadata.version('rt')
+    # The short X.Y version.
+    version = '.'.join(release.split('.')[:2])
+except importlib.metadata.PackageNotFoundError:
+    version = 'unknown'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/rt/rest1.py 
new/python-rt-3.4.0/rt/rest1.py
--- old/python-rt-3.3.6/rt/rest1.py     2025-04-24 15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/rt/rest1.py     2025-11-28 08:11:12.000000000 +0100
@@ -383,7 +383,7 @@
         if not pairs.get('id', '').startswith('ticket/'):
             raise UnexpectedMessageFormatError("Response from RT didn't 
contain a valid ticket_id")
         _, _, numerical_id = pairs['id'].partition('/')
-        ticket = typing.cast(dict[str, typing.Sequence[str]], pairs)
+        ticket = typing.cast('dict[str, typing.Sequence[str]]', pairs)
         ticket['numerical_id'] = numerical_id
         for key in ['Requestors', 'Cc', 'AdminCc']:
             try:
@@ -791,11 +791,11 @@
         ):
             return None
         items = typing.cast(
-            list[dict[str, typing.Union[str, list[tuple[int, str]]]]],
+            'list[dict[str, typing.Union[str, list[tuple[int, str]]]]]',
             [self.__parse_response_dict(msg, ['Content', 'Attachments']) for 
msg in msgs.split('\n--\n')],
         )
         for body in items:
-            attachments = typing.cast(str, body.get('Attachments', ''))
+            attachments = typing.cast('str', body.get('Attachments', ''))
             body['Attachments'] = self.__parse_response_numlist(attachments)
         return items
 
@@ -1029,7 +1029,7 @@
         :raises UnexpectedMessageFormatError: Unexpected format of returned 
message.
         """
         _msg = 
self.__request(f'ticket/{ticket_id}/attachments/{attachment_id}', 
text_response=False)
-        msg = typing.cast(bytes, _msg).split(b'\n')
+        msg = typing.cast('bytes', _msg).split(b'\n')
         if (len(msg) > 2) and (
             self.RE_PATTERNS['invalid_attachment_pattern_bytes'].match(msg[2])
             or self.RE_PATTERNS['does_not_exist_pattern_bytes'].match(msg[2])
@@ -1086,7 +1086,7 @@
         Returns: Bytes with content of attachment or None if ticket or
                  attachment does not exist.
         """
-        msg = typing.cast(bytes, 
self.__request(f'ticket/{ticket_id}/attachments/{attachment_id}/content', 
text_response=False))
+        msg = typing.cast('bytes', 
self.__request(f'ticket/{ticket_id}/attachments/{attachment_id}/content', 
text_response=False))
         lines = msg.split(b'\n', 3)
         if (len(lines) == 4) and (
             
self.RE_PATTERNS['invalid_attachment_pattern_bytes'].match(lines[2])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/rt/rest2.py 
new/python-rt-3.4.0/rt/rest2.py
--- old/python-rt-3.3.6/rt/rest2.py     2025-04-24 15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/rt/rest2.py     2025-11-28 08:11:12.000000000 +0100
@@ -148,7 +148,7 @@
         self.logger.debug('Request body: %s', 
response.request.content.decode('utf8', 'ignore'))
         self.logger.debug('Response status code: %s', 
str(response.status_code))
         self.logger.debug('Response content:')
-        self.logger.debug(response.content.decode())
+        self.logger.debug(response.content.decode(errors='ignore'))
 
     def __request(
         self,
@@ -699,13 +699,13 @@
 
         return bool(msg[0])
 
-    def get_ticket_history(self, ticket_id: typing.Union[str, int]) -> 
typing.Optional[list[dict[str, typing.Any]]]:
+    def get_ticket_history(self, ticket_id: typing.Union[str, int]) -> 
list[dict[str, typing.Any]]:
         """Get set of short history items.
 
         :param ticket_id: ID of ticket
         :returns: List of history items ordered increasingly by time of event.
                   Each history item is a tuple containing (id, Description).
-                  Returns None if ticket does not exist.
+                  Returns an empty list if ticket does not exist.
         """
         transactions = self.__paged_request(
             f'ticket/{ticket_id}/history',
@@ -849,7 +849,11 @@
 
         return bool(msg[0])
 
-    def get_attachments(self, ticket_id: typing.Union[str, int]) -> 
typing.Sequence[dict[str, str]]:
+    def get_attachments(
+        self,
+        ticket_id: typing.Union[str, int],
+        query_filter: typing.Optional[list[dict[str, str]]] = None,
+    ) -> typing.Sequence[dict[str, str]]:
         """Get attachment list for a given ticket.
 
         Example of a return result:
@@ -868,39 +872,51 @@
             ]
 
         :param ticket_id: ID of ticket
+        :param query_filter: JSON search filter, defaults to "filename is not 
empty"
         :returns: List of tuples for attachments belonging to given ticket.
                   Tuple format: (id, name, content_type, size)
                   Returns None if ticket does not exist.
         """
         attachments = []
 
+        if query_filter is None:
+            query_filter = [{'field': 'Filename', 'operator': 'IS NOT', 
'value': ''}]
+
         for item in self.__paged_request(
             f'ticket/{ticket_id}/attachments',
-            json_data=[{'field': 'Filename', 'operator': 'IS NOT', 'value': 
''}],
+            json_data=query_filter,
             params={'fields': 'Filename,ContentType,ContentLength'},
         ):
             attachments.append(item)
 
         return attachments
 
-    def get_attachments_ids(self, ticket_id: typing.Union[str, int]) -> 
typing.Optional[list[int]]:
+    def get_attachments_ids(
+        self,
+        ticket_id: typing.Union[str, int],
+        query_filter: typing.Optional[list[dict[str, str]]] = None,
+    ) -> list[int]:
         """Get IDs of attachments for given ticket.
 
         :param ticket_id: ID of ticket
+        :param query_filter: JSON search filter, defaults to "filename is not 
empty"
         :returns: List of IDs (type int) of attachments belonging to given
-                  ticket. Returns None if ticket does not exist.
+                  ticket. Returns an empty list if ticket does not exist.
         """
         attachments = []
 
+        if query_filter is None:
+            query_filter = [{'field': 'Filename', 'operator': 'IS NOT', 
'value': ''}]
+
         for item in self.__paged_request(
             f'ticket/{ticket_id}/attachments',
-            json_data=[{'field': 'Filename', 'operator': 'IS NOT', 'value': 
''}],
+            json_data=query_filter,
         ):
             attachments.append(int(item['id']))
 
         return attachments
 
-    def get_attachment(self, attachment_id: typing.Union[str, int]) -> 
typing.Optional[dict]:
+    def get_attachment(self, attachment_id: typing.Union[str, int]) -> dict:
         """Get attachment.
 
         :param attachment_id: ID of attachment to fetch
@@ -1205,7 +1221,7 @@
 
             raise  # pragma: no cover
 
-    def get_queue(self, queue_id: typing.Union[str, int]) -> 
typing.Optional[dict[str, typing.Any]]:
+    def get_queue(self, queue_id: typing.Union[str, int]) -> dict[str, 
typing.Any]:
         """Get queue details.
 
         Example of a return result:
@@ -1430,7 +1446,7 @@
 
             raise  # pragma: no cover
 
-    def get_links(self, ticket_id: typing.Union[str, int]) -> 
typing.Optional[list[dict[str, str]]]:
+    def get_links(self, ticket_id: typing.Union[str, int]) -> list[dict[str, 
str]]:
         """Gets the ticket links for a single ticket.
 
         Example of a return result:
@@ -1456,8 +1472,7 @@
                         * child
                         * refers-to
                         * referred-to-by
-
-                  None is returned if ticket does not exist.
+        :raises NotFoundError: If there is no ticket with the specified 
ticket_id.
         :raises UnexpectedMessageFormatError: In case that returned status 
code is not 200
         """
         ticket = self.get_ticket(ticket_id)
@@ -1585,6 +1600,155 @@
 
         return msg[0].lower().startswith('owner changed')
 
+    def get_catalog(self, catalog_id: typing.Union[str, int]) -> dict[str, 
typing.Any]:
+        """
+        Get catalog.
+
+        :param catalog_id: Catalog ID.
+        :return: Catalog.
+                id: int
+                Lifecycle: str
+                Disabled: str
+                _hyperlinks: list[dict[dict[str, str | int]]]
+                LastUpdated: str
+                LastUpdatedBy: dict[str, str]
+                Created: str
+                Creator: dict[str, str]
+                Description: str
+                Name: str
+                Contact: list[str, str]
+                HeldBy: list[str, str]
+        """
+        response = self.__request(f'catalog/{catalog_id}')
+
+        self.logger.debug(str(response))
+
+        if not isinstance(response, dict):
+            raise UnexpectedResponseError(str(response))
+
+        return response
+
+    def get_asset(self, asset_id: typing.Union[str, int]) -> dict[str, 
typing.Any]:
+        """
+        Get asset.
+
+        :param asset_id: Asset ID.
+        :return: Asset.
+                id: int
+                Lifecycle: str
+                Disabled: str
+                _hyperlinks: list[dict[dict[str, str | int]]]
+                LastUpdated: str
+                LastUpdatedBy: dict[str, str]
+                Created: str
+                Creator: dict[str, str]
+                Description: str
+                Name: str
+                Contact: list[str, str]
+                HeldBy: list[str, str]
+                Catalog: dict[str, str]
+                Status: str
+                Owner: dict[str, str]
+                CustomFields: list[dict[str, typing.Any]]
+        """
+        response = self.__request(f'asset/{asset_id}')
+
+        self.logger.debug(str(response))
+
+        if not isinstance(response, dict):
+            raise UnexpectedResponseError(str(response))
+
+        return response
+
+    def create_asset(self, name: str, catalog: typing.Union[str, int], 
**kwargs: typing.Any) -> int:
+        """
+        Create a new asset in a catalog.
+
+        :param name: Asset name.
+        :param catalog: Catalog name or ID.
+        :param kwargs: Name, Description, Status, CustomFields, RefersTo, etc.
+        :return: ID of the asset.
+        """
+        response = self.__request('asset', json_data={'Name': name, 'Catalog': 
catalog, **kwargs})
+
+        self.logger.debug(str(response))
+
+        if not isinstance(response, dict):
+            raise UnexpectedResponseError(str(response))
+
+        return int(response['id'])
+
+    def edit_asset(self, asset_id: typing.Union[str, int], **kwargs: 
typing.Any) -> bool:
+        """
+        Edit an existing asset.
+
+        :param asset_id: Asset ID.
+        :param kwargs: Name, Description, Status, CustomFields, RefersTo, etc.
+        :return: ``True``
+                      Operation was successful
+                  ``False``
+                      Failed (status code != 200)
+        """
+        response = self.__request_put(f'asset/{asset_id}', kwargs)
+
+        self.logger.debug(str(response))
+
+        return isinstance(response, list)
+
+    def search_assets(
+        self, catalog_id: typing.Union[str, int], search_params: 
list[dict[str, typing.Any]], fields: str = "Owner,Description,Status"
+    ) -> typing.Iterator[dict[str, typing.Any]]:
+        """
+        Search assets in a catalog.
+
+        Example::
+
+            client = Rt(...)
+            client.search_assets(1, [{"field": "Name", "value": 
"NameOfMyAsset"}])
+
+        :param catalog_id: Catalog ID.
+        :param search_params: Params used to filter the results.
+            field: str
+            value: str | int
+            operator: Literal[">", "<", "=", "!=", "LIKE", "NOT LIKE", ">=", 
"<="] | None
+        :param fields: Fields to return separated by a comma.
+        :return: Found assets. The following is returned with the default 
`fields`
+            {
+                'Description': '',
+                'id': '1',
+                '_url': 'http://localhost:8080/REST/2.0/asset/1',
+                'Owner': {'_url': 
'http://localhost:8080/REST/2.0/user/Nobody', 'id': 'Nobody', 'type': 'user'},
+                'Status': 'new',
+                'type': 'asset'
+            }
+        """
+        search_params.append({'field': 'Catalog', 'value': catalog_id, 
'operator': '='})
+
+        yield from self.__paged_request('assets', json_data=search_params, 
params={"fields": fields})
+
+    def get_asset_history(self, asset_id: typing.Union[str, int]) -> 
typing.Iterator[dict[str, typing.Any]]:
+        """
+        Get asset history.
+
+        :param asset_id: Asset ID.
+        :return: History - transactions.
+            Type: str
+            type: str
+            _url: str
+            Creator: dict[str, str | int]
+            Created: str
+            Description: str
+            _hyperlinks: list[dict[str, int | str]]
+            id: str
+        """
+        yield from self.__paged_request(
+            f'asset/{asset_id}/history',
+            params={
+                'fields': 'Type,Creator,Created,Description,_hyperlinks',
+                'fields[Creator]': 'id,Name,RealName,EmailAddress',
+            },
+        )
+
 
 class AsyncRt:
     r""":term:`API` for Request Tracker according to
@@ -1655,7 +1819,7 @@
         self.logger.debug('Request body: %s', 
response.request.content.decode('utf8', 'ignore'))
         self.logger.debug('Response status code: %s', 
str(response.status_code))
         self.logger.debug('Response content:')
-        self.logger.debug(response.content.decode())
+        self.logger.debug(response.content.decode(errors='ignore'))
 
     async def __request(
         self,
@@ -2359,7 +2523,11 @@
 
         return bool(msg[0])
 
-    async def get_attachments(self, ticket_id: typing.Union[str, int]) -> 
collections.abc.AsyncIterator:
+    async def get_attachments(
+        self,
+        ticket_id: typing.Union[str, int],
+        query_filter: typing.Optional[list[dict[str, str]]] = None,
+    ) -> collections.abc.AsyncIterator:
         """Get attachment list for a given ticket.
 
         Example of a return result:
@@ -2378,30 +2546,42 @@
             ]
 
         :param ticket_id: ID of ticket
+        :param query_filter: JSON search filter, defaults to "filename is not 
empty"
         :returns: Iterator of attachments belonging to given ticket. 
collections.abc.AsyncIterator[typing.Dict[str, str]]
         """
+        if query_filter is None:
+            query_filter = [{'field': 'Filename', 'operator': 'IS NOT', 
'value': ''}]
+
         async for item in self.__paged_request(
             f'ticket/{ticket_id}/attachments',
-            json_data=[{'field': 'Filename', 'operator': 'IS NOT', 'value': 
''}],
+            json_data=query_filter,
             params={'fields': 'Filename,ContentType,ContentLength'},
         ):
             yield item
 
-    async def get_attachments_ids(self, ticket_id: typing.Union[str, int]) -> 
collections.abc.AsyncIterator:
+    async def get_attachments_ids(
+        self,
+        ticket_id: typing.Union[str, int],
+        query_filter: typing.Optional[list[dict[str, str]]] = None,
+    ) -> collections.abc.AsyncIterator:
         """Get IDs of attachments for given ticket.
 
-        :param ticket_id: ID of ticket
+        :param ticket_id: ID of
+        :param query_filter: JSON search filter, defaults to "filename is not 
empty"
         :returns: Iterator of IDs (type int) of attachments belonging to given
                   ticket.
                   collections.abc.AsyncIterator[int]
         """
+        if query_filter is None:
+            query_filter = [{'field': 'Filename', 'operator': 'IS NOT', 
'value': ''}]
+
         async for item in self.__paged_request(
             f'ticket/{ticket_id}/attachments',
-            json_data=[{'field': 'Filename', 'operator': 'IS NOT', 'value': 
''}],
+            json_data=query_filter,
         ):
             yield int(item['id'])
 
-    async def get_attachment(self, attachment_id: typing.Union[str, int]) -> 
typing.Optional[dict]:
+    async def get_attachment(self, attachment_id: typing.Union[str, int]) -> 
dict:
         """Get attachment.
 
         :param attachment_id: ID of attachment to fetch
@@ -2706,7 +2886,7 @@
 
             raise  # pragma: no cover
 
-    async def get_queue(self, queue_id: typing.Union[str, int]) -> 
typing.Optional[dict[str, typing.Any]]:
+    async def get_queue(self, queue_id: typing.Union[str, int]) -> dict[str, 
typing.Any]:
         """Get queue details.
 
         Example of a return result:
@@ -2957,8 +3137,7 @@
                         * child
                         * refers-to
                         * referred-to-by
-
-                  None is returned if ticket does not exist.
+        :raises NotFoundError: If there is no ticket with the specified 
ticket_id.
         :raises UnexpectedMessageFormatError: In case that returned status 
code is not 200
         """
         ticket = await self.get_ticket(ticket_id)
@@ -3085,3 +3264,154 @@
         self.logger.debug(str(msg))
 
         return msg[0].lower().startswith('owner changed')
+
+    async def get_catalog(self, catalog_id: typing.Union[str, int]) -> 
dict[str, typing.Any]:
+        """
+        Get catalog.
+
+        :param catalog_id: Catalog ID.
+        :return: Catalog.
+                id: int
+                Lifecycle: str
+                Disabled: str
+                _hyperlinks: list[dict[dict[str, str | int]]]
+                LastUpdated: str
+                LastUpdatedBy: dict[str, str]
+                Created: str
+                Creator: dict[str, str]
+                Description: str
+                Name: str
+                Contact: list[str, str]
+                HeldBy: list[str, str]
+        """
+        response = await self.__request(f'catalog/{catalog_id}')
+
+        self.logger.debug(str(response))
+
+        if not isinstance(response, dict):
+            raise UnexpectedResponseError(str(response))
+
+        return response
+
+    async def get_asset(self, asset_id: typing.Union[str, int]) -> dict[str, 
typing.Any]:
+        """
+        Get asset.
+
+        :param asset_id: Asset ID.
+        :return: Asset.
+                id: int
+                Lifecycle: str
+                Disabled: str
+                _hyperlinks: list[dict[dict[str, str | int]]]
+                LastUpdated: str
+                LastUpdatedBy: dict[str, str]
+                Created: str
+                Creator: dict[str, str]
+                Description: str
+                Name: str
+                Contact: list[str, str]
+                HeldBy: list[str, str]
+                Catalog: dict[str, str]
+                Status: str
+                Owner: dict[str, str]
+                CustomFields: list[dict[str, typing.Any]]
+        """
+        response = await self.__request(f'asset/{asset_id}')
+
+        self.logger.debug(str(response))
+
+        if not isinstance(response, dict):
+            raise UnexpectedResponseError(str(response))
+
+        return response
+
+    async def create_asset(self, name: str, catalog: typing.Union[str, int], 
**kwargs: typing.Any) -> int:
+        """
+        Create a new asset in a catalog.
+
+        :param name: Asset name.
+        :param catalog: Catalog name or ID.
+        :param kwargs: Name, Description, Status, CustomFields, RefersTo, etc.
+        :return: ID of the asset.
+        """
+        response = await self.__request('asset', json_data={'Name': name, 
'Catalog': catalog, **kwargs})
+
+        self.logger.debug(str(response))
+
+        if not isinstance(response, dict):
+            raise UnexpectedResponseError(str(response))
+
+        return int(response['id'])
+
+    async def edit_asset(self, asset_id: typing.Union[str, int], **kwargs: 
typing.Any) -> bool:
+        """
+        Edit an existing asset.
+
+        :param asset_id: Asset ID.
+        :param kwargs: Name, Description, Status, CustomFields, RefersTo, etc.
+        :return: ``True``
+                      Operation was successful
+                  ``False``
+                      Failed (status code != 200)
+        """
+        response = await self.__request_put(f'asset/{asset_id}', kwargs)
+
+        self.logger.debug(str(response))
+
+        return isinstance(response, list)
+
+    async def search_assets(
+        self, catalog_id: typing.Union[str, int], search_params: 
list[dict[str, typing.Any]], fields: str = "Owner,Description,Status"
+    ) -> collections.abc.AsyncIterator[dict[str, typing.Any]]:
+        """
+        Search assets in a catalog.
+
+        Example::
+
+            client = AsyncRt(...)
+            await client.search_assets(1, [{"field": "Name", "value": 
"NameOfMyAsset"}])
+
+        :param catalog_id: Catalog ID.
+        :param search_params: Params used to filter the results.
+            field: str
+            value: str | int
+            operator: Literal[">", "<", "=", "!=", "LIKE", "NOT LIKE", ">=", 
"<="] | None
+        :param fields: Fields to return separated by a comma.
+        :return: Found assets. The following is returned with the default 
`fields`
+            {
+                'Description': '',
+                'id': '1',
+                '_url': 'http://localhost:8080/REST/2.0/asset/1',
+                'Owner': {'_url': 
'http://localhost:8080/REST/2.0/user/Nobody', 'id': 'Nobody', 'type': 'user'},
+                'Status': 'new',
+                'type': 'asset'
+            }
+        """
+        search_params.append({'field': 'Catalog', 'value': catalog_id, 
'operator': '='})
+
+        async for item in self.__paged_request('assets', 
json_data=search_params, params={"fields": fields}):
+            yield item
+
+    async def get_asset_history(self, asset_id: typing.Union[str, int]) -> 
collections.abc.AsyncIterator[list[dict[str, typing.Any]]]:
+        """
+        Get asset history.
+
+        :param asset_id: Asset ID.
+        :return: History - transactions.
+            Type: str
+            type: str
+            _url: str
+            Creator: dict[str, str | int]
+            Created: str
+            Description: str
+            _hyperlinks: list[dict[str, int | str]]
+            id: str
+        """
+        async for transaction in self.__paged_request(
+            f'asset/{asset_id}/history',
+            params={
+                'fields': 'Type,Creator,Created,Description,_hyperlinks',
+                'fields[Creator]': 'id,Name,RealName,EmailAddress',
+            },
+        ):
+            yield transaction
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/tests/test_basic.py 
new/python-rt-3.4.0/tests/test_basic.py
--- old/python-rt-3.3.6/tests/test_basic.py     2025-04-24 15:35:48.000000000 
+0200
+++ new/python-rt-3.4.0/tests/test_basic.py     2025-11-28 08:11:12.000000000 
+0100
@@ -62,7 +62,7 @@
 
     # empty search result
     search_result = list(rt_connection.search(Subject=ticket_subject))
-    assert not len(search_result)
+    assert not search_result
 
     # create
     ticket_id = rt_connection.create_ticket(subject=ticket_subject, 
content=ticket_text, queue=RT_QUEUE)
@@ -440,3 +440,27 @@
 
     with pytest.raises(rt.exceptions.NotFoundError):
         rt_connection.delete_queue(f'Queue {random_string()}')
+
+
+def test_catalog(rt_connection: rt.rest2.Rt):
+    catalog = rt_connection.get_catalog(1)
+    assert catalog['id'] == 1
+
+
+def test_assets(rt_connection: rt.rest2.Rt):
+    asset_id = rt_connection.create_asset('test', 1, Creator='root')
+    assert asset_id
+
+    asset = rt_connection.get_asset(asset_id)
+    assert asset['id'] == asset_id
+
+    asset_history = rt_connection.get_asset_history(asset_id)
+    assert len(list(asset_history)) == 1
+
+    asset_edited = rt_connection.edit_asset(asset_id, Name='test2')
+    assert asset_edited
+
+    search = rt_connection.search_assets(1, [{'field': 'Name', 'value': 
'test2'}])
+    items = list(search)
+    assert len(items) == 1
+    assert items[0]["Status"] == "new"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/tests/test_basic_async.py 
new/python-rt-3.4.0/tests/test_basic_async.py
--- old/python-rt-3.3.6/tests/test_basic_async.py       2025-04-24 
15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/tests/test_basic_async.py       2025-11-28 
08:11:12.000000000 +0100
@@ -65,7 +65,7 @@
 
     # empty search result
     search_result = [item async for item in 
async_rt_connection.search(Subject=ticket_subject)]
-    assert not len(search_result)
+    assert not search_result
 
     # create
     ticket_id = await 
async_rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, 
queue=RT_QUEUE)
@@ -451,3 +451,29 @@
 
     with pytest.raises(rt.exceptions.NotFoundError):
         await async_rt_connection.delete_queue(f'Queue {random_string()}')
+
+
[email protected]
+async def test_catalog(async_rt_connection: rt.rest2.AsyncRt):
+    catalog = await async_rt_connection.get_catalog(1)
+    assert catalog['id'] == 1
+
+
[email protected]
+async def test_assets(async_rt_connection: rt.rest2.AsyncRt):
+    asset_id = await async_rt_connection.create_asset('test', 1, 
Creator='root')
+    assert asset_id
+
+    asset = await async_rt_connection.get_asset(asset_id)
+    assert asset['id'] == asset_id
+
+    asset_history = [item async for item in 
async_rt_connection.get_asset_history(asset_id)]
+    assert len(asset_history) == 1
+
+    asset_edited = await async_rt_connection.edit_asset(asset_id, 
Name='test2async')
+    assert asset_edited
+
+    search = async_rt_connection.search_assets(1, [{'field': 'Name', 'value': 
'test2async'}])
+    items = [item async for item in search]
+    assert len(items) == 1
+    assert items[0]["Status"] == "new"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/tests/test_rest1.py 
new/python-rt-3.4.0/tests/test_rest1.py
--- old/python-rt-3.3.6/tests/test_rest1.py     2025-04-24 15:35:48.000000000 
+0200
+++ new/python-rt-3.4.0/tests/test_rest1.py     2025-11-28 08:11:12.000000000 
+0100
@@ -1,6 +1,6 @@
 """Tests for Rt - Python interface to Request Tracker :term:`API`"""
 
-# ruff: noqa: S101, S105, S311
+# ruff: noqa: S311
 
 __license__ = """ Copyright (C) 2013 CZ.NIC, z.s.p.o.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/tests/test_tickets.py 
new/python-rt-3.4.0/tests/test_tickets.py
--- old/python-rt-3.3.6/tests/test_tickets.py   2025-04-24 15:35:48.000000000 
+0200
+++ new/python-rt-3.4.0/tests/test_tickets.py   2025-11-28 08:11:12.000000000 
+0100
@@ -1,6 +1,6 @@
 """Tests for python-rt / REST2 - Python interface to Request Tracker 
:term:`API`."""
 
-# ruff: noqa: S101, S105, S311
+# ruff: noqa: S101
 
 __license__ = """ Copyright (C) 2013 CZ.NIC, z.s.p.o.
     Copyright (c) 2021 CERT Gouvernemental (GOVCERT.LU)
@@ -59,6 +59,16 @@
     att_content = 
base64.b64decode(rt_connection.get_attachment(att_id)['Content'])
     assert att_content == attachment_content
 
+    # test filter parameter
+    att_ids = rt_connection.get_attachments_ids(ticket_id, query_filter=None)
+    assert len(att_ids) == 1
+
+    att_ids = rt_connection.get_attachments_ids(ticket_id, query_filter=[])
+    assert len(att_ids) == 3
+
+    att_ids = rt_connection.get_attachments_ids(ticket_id, 
query_filter=[{'field': 'Filename', 'value': 'non-existant.txt'}])
+    assert not att_ids
+
 
 def test_ticket_take(rt_connection: rt.rest2.Rt):
     """Test take/untake."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/python-rt-3.3.6/tests/test_tickets_async.py 
new/python-rt-3.4.0/tests/test_tickets_async.py
--- old/python-rt-3.3.6/tests/test_tickets_async.py     2025-04-24 
15:35:48.000000000 +0200
+++ new/python-rt-3.4.0/tests/test_tickets_async.py     2025-11-28 
08:11:12.000000000 +0100
@@ -64,6 +64,21 @@
     att_content = base64.b64decode((await 
async_rt_connection.get_attachment(att_id))['Content'])
     assert att_content == attachment_content
 
+    # test filter parameter
+    att_ids = [item async for item in 
async_rt_connection.get_attachments_ids(ticket_id, query_filter=None)]
+    assert len(att_ids) == 1
+
+    att_ids = [item async for item in 
async_rt_connection.get_attachments_ids(ticket_id, query_filter=[])]
+    assert len(att_ids) == 3
+
+    att_ids = [
+        item
+        async for item in async_rt_connection.get_attachments_ids(
+            ticket_id, query_filter=[{'field': 'Filename', 'value': 
'non-existant.txt'}]
+        )
+    ]
+    assert not att_ids
+
 
 @pytest.mark.asyncio
 async def test_ticket_take(async_rt_connection: rt.rest2.AsyncRt):

Reply via email to