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):
