Repository: python-rt/python-rt (language: python)

Issue:
Do not remove empty fields
There are some fields like 'Cc' or 'AdminCc' that are not returned by get_ticket() when empty.
They are being removed by the `if lines` condition of __parse_response_dict()


Discussion / hints:

Fix patch:
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..94763f6
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+
+[{*.pyw,*.py}]
+indent_size = 4
+tab_width = 4
+max_line_length = 240
+ij_continuation_indent_size = 4
+ij_python_blank_line_at_file_end = true
+
+[{*.yml_sample,*.yaml_sample,*.yml,*.yaml}]
+indent_size = 2
diff --git a/.gitignore b/.gitignore
index 524badf..d569225 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,5 +8,6 @@ rt.egg-info
 .idea
 .vscode
 .mypy_cache
+.pytest_cache
 doc/_build/
 venv
diff --git a/.pylintrc b/.pylintrc
index b4e4d22..fdec346 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -52,7 +52,7 @@ persistent=yes
 
 # Minimum Python version to use for version dependent checks. Will default to
 # the version used to run pylint.
-py-version=3.5
+py-version=3.7
 
 # When enabled, pylint would attempt to guess common misconfiguration and emit
 # user-friendly hints instead of false-positive error messages.
@@ -91,7 +91,9 @@ disable=raw-checker-failed,
         too-many-public-methods,
         too-many-arguments,
         invalid-name,
-        too-many-branches
+        too-many-branches,
+        similarities,
+        too-many-locals
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
diff --git a/CHANGES b/CHANGELOG.md
similarity index 67%
rename from CHANGES
rename to CHANGELOG.md
index a6283f5..fabf57f 100644
--- a/CHANGES
+++ b/CHANGELOG.md
@@ -1,29 +1,59 @@
-v2.2.2, 2022-04-08
+# Changelog
+All notable changes to this project will be documented in this file.
+
+This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [v3.0.0], 2022-05-17
+The following is a major release of the `rt` library.
+There is support for the REST API version 1 as well as version 2.
+Please note that this release contains breaking changes and requires adaptations to existing code, even if you are
+sticking to version 1 of the API.
+These changes were necessary in order to properly support both API versions.
+
+### Added
+- RT REST2 support was added and is mostly on par with the REST1 support (differences are a result of the REST2 API implementation differences in RT).
+REST2 is a modern API based on JSON exchanges and thus the complex parsing of responses and request construction are no longer needed.
+
+### Changes
+- Existing exception classes were renamed to adhere to the naming convention (https://peps.python.org/pep-0008/#exception-names).
+  - In case you do catch specific `rt` exceptions, a simple search/replace will do, see the changelog page in the documentation for details.
+- Importing the `rt` class changed in order to better accommodate the new `rest2` implementation.
+  - Where one use to be able to import `rt` using:
+    `from rt import Rt`
+  
+    you now have to use the following syntax:
+  
+    `from rt.rest1 import Rt`
+- Importing the `rt` module does no longer import all exceptions but only the core `RtError` exception.
+If you require other exceptions, please import them from `rt.exceptions`.
+- Use pytest instead of nose.
+
+## [v2.2.2], 2022-04-08
 - Fix bug in the get_ticket would omit certain fields in case they were empty instead of returning an empty list as was the previous behavior (#70).
 - Add tests for verifying correct return result for AdminCc, Cc and Requestor fields.
 
-v2.2.1, 2021-11-26
+## [v2.2.1], 2021-11-26
 - Fix bug in get_attachment_content which was a workaround for a bug in RT <=4.2 (trailing new-lines) but which was fixed in RT >=4.2. This made tests fail and return falsely stripped attachment content.
 
-v2.2.0, 2021-11-15
+## [v2.2.0], 2021-11-15
 - Search has a parameter fields that can be used to return only particular fields for tickets. In some cases I noticed it will improve the speed of the query completion if you only need specific fields (#65 by @kimmoal).
 
-v2.1.1, 2021-03-23
+## [v2.1.1], 2021-03-23
 - Fix support for custom field values containing newlines in API responses (#10, #11)
   (the previous change in v1.0.11 fixed API requests) (#64)
 
-v2.1.0, 2021-02-25
+## [v2.1.0], 2021-02-25
 - Add the possibility to provide cookies as dict to authenticate (#60)
 - Add 'Referer' header for CSRF check when cookies are used for authentication (#60)
 - Add IS and IS NOT operators to search (#57)
 
-v2.0.1, 2020-08-07
+## [v2.0.1], 2020-08-07
 - Fix UnicodeDecodeError in logging code for non-text attachments (#50, #51)
 - Documentation: Add a search example (#49)
 - edit_ticket: Handle possible empty responses: When a ticket is not modified, at least with RT 4.x, an empty
   response could be returned. Gracefully handle that as success. (#47, #48)
 
-v2.0.0, 2020-02-11
+## [v2.0.0], 2020-02-11
 - Drop Python2 support
 - Adjust Travis tests for Python3-only, and add v3.8
 - Add inline typing
@@ -35,12 +65,12 @@ v2.0.0, 2020-02-11
   "http_auth" with an instance of either object. This allows for more flexibility with various other alternative
   authentication methods.
 
-v1.0.13, 2020-02-06
+## [v1.0.13], 2020-02-06
 - Add deprecation warning for in the next major release unsupported parameters (basic_auth, digest_auth).
   They are now replaced with http_auth.
 - Fix problematic default method parameters ("{}" and "[]").
 
-v1.0.12, 2019-10-25
+## [v1.0.12], 2019-10-25
 - Travis CI Docker tests
 - RT 4.4 fixes
 - Support multiline CF values in create_ticket and edit_ticket.
@@ -50,7 +80,7 @@ v1.0.12, 2019-10-25
 - Add platform independent url joining / Allow testing on Windows
 - Add numerical_id to get_ticket result
 
-v1.0.11, 2018-07-16
+## [v1.0.11], 2018-07-16
 - Added parameter to set the content type in reply() and comment() (#12).
 - Added parameter Format to search() (#17).
 - Tests: Update to new demo instance, fixing tests.
@@ -58,7 +88,7 @@ v1.0.11, 2018-07-16
 - Fix support for custom field names containing colons (#37).
 - Fix support for custom field values containing newlines (#11).
 
-v1.0.10, 2017-02-22
+## [v1.0.10], 2017-02-22
 - PEP8 fixes
 - update .travis.yml to update python interpreter list and some other small changes
 - prefer format over % (PEP 3101)
@@ -71,16 +101,16 @@ v1.0.10, 2017-02-22
   "returned types inconsistent in get_ticket"
   Add splitting for Cc and AdminCc
 
-v1.0.9, 2016-06-22
+## [v1.0.9], 2016-06-22
 - added ability to steal, untake, and explicitly take tickets
 - fixed create_ticket return value when provided an invalid custom field
 
-v1.0.8, 2014-05-29
+## [v1.0.8], 2014-05-29
 - added ability to search all queues
 - added RtError super class
 - fixed compatibility issues with Python 2.6
 
-v1.0.7, 2013-10-01
+## [v1.0.7], 2013-10-01
 - unit tests
 - own exceptions
 - added create_user, create_queue, edit_user, edit_queue methods
@@ -90,28 +120,28 @@ v1.0.7, 2013-10-01
 - custom query to search method
 - strict binary handling with attachments
 
-v1.0.6, 2013-09-05
+## [v1.0.6], 2013-09-05
 - added support for HTTP basic and digest authentication
 - specification of errors to different exceptions
 
-v1.0.5, 2013-04-26
+## [v1.0.5], 2013-04-26
 - fixed decoding of utf-8 only when needed
 - updated search function to support various
   lookup operators and sorting
 
-v1.0.4, 2013-03-21
+## [v1.0.4], 2013-03-21
 - default queue added to init parameters
 
-v1.0.3, 2013-03-06
+## [v1.0.3], 2013-03-06
 - python-requests 1.x compatible
 
-v1.0.2, 2013-02-18
+## [v1.0.2], 2013-02-18
 - HTTP proxy support
 - Support for multilinks in get_links
 
-v1.0.1, 2013-01-10
+## [v1.0.1], 2013-01-10
 - Updated docstrings
 - Added Sphinx documentation
 
-v1.0.0, 2012-08-03
+## [v1.0.0], 2012-08-03
 - Initial release
diff --git a/MANIFEST.in b/MANIFEST.in
index 48a1ef3..893a73b 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -2,7 +2,6 @@ include AUTHORS
 include CHANGES
 include LICENSE
 include README.rst
-include test_rt.py
 recursive-include doc *
 recursive-include tests *
 
diff --git a/README.rst b/README.rst
index 0cce618..7200413 100644
--- a/README.rst
+++ b/README.rst
@@ -14,24 +14,39 @@
  Rt - Python interface to Request Tracker API 
 ==============================================
 
-Python implementation of REST API described here: https://rt-wiki.bestpractical.com/wiki/REST
+Python implementation of REST API described here:
+ - https://rt-wiki.bestpractical.com/wiki/REST
+ - https://docs.bestpractical.com/rt/5.0.2/RT/REST2.html
 
-**Note:** Please note that starting with the major release of v2.0.0, this library requires a Python version >= 3.5.
-In case you still require a Python 2 version or one compatible with Python < 3.5, please use a version < 2.0.0 of this library.
+.. csv-table:: Python version compatibility:
+   :header: "Python", "rt"
+   :widths: 15, 15
 
+   "2.7", "< 2.0.0"
+   ">= 3.5, <3.7", ">= 2.0.0, < 3.0.0"
+   ">= 3.7", ">= 3.0.0"
 
-REQUIREMENTS
-============
+ℹ️ **Note**:
+    Please note that starting with the major release of v3.0.0, this library requires Python version >= 3.7.
+    See the *Python version compatibility* table above for more detailed information.
 
-This module uses following Python modules:
+⚠️ **Warning**:
+    Though version 3.x still supports RT REST API version 1, it contains minor breaking changes. Please see the changelog
+    in the documentation for details.
 
-- requests (http://docs.python-requests.org/)
+Requirements
+============
 
-Unit-tests are implemented using:
-- nose (http://nose.readthedocs.org)
+This module uses following Python modules:
+ - requests (http://docs.python-requests.org/)
+ - requests-toolbelt (https://pypi.org/project/requests-toolbelt/)
+ - typing-extensions (depending on python version)
 
+Documentation
+=============
+https://python-rt.readthedocs.io/en/latest/
 
-INSTALLATION
+Installation
 ============
 
 Install the python-rt package using::
@@ -39,7 +54,7 @@ Install the python-rt package using::
   pip install rt
 
 
-LICENCE
+Licence
 =======
 
 This module is distributed under the terms of GNU General Public Licence v3
@@ -47,49 +62,58 @@ and was developed by CZ.NIC Labs - research and development department of
 CZ.NIC association - top level domain registry for .CZ.  Copy of the GNU
 General Public License is distributed along with this module.
 
-USAGE
+Usage
 =====
 
 An example is worth a thousand words::
 
-    >>> import rt
-    >>> tracker = rt.Rt('http://localhost/rt/REST/1.0/', 'user_login', 'user_pass')
-    >>> tracker.login()
-    True
+    >>> import rt.rest2
+    >>> import requests.auth
+    >>> tracker = rt.rest2.Rt('http://localhost/rt/REST/2.0/', http_auth=requests.auth.HTTPBasicAuth('root', 'password'))
     >>> map(lambda x: x['id'], tracker.search(Queue='helpdesk', Status='open'))
-    ['ticket/1', 'ticket/2', 'ticket/10', 'ticket/15']
-    >>> tracker.create_ticket(Queue='helpdesk', \
-    ... Subject='Coffee (important)', Text='Help I Ran Out of Coffee!')
+    ['1', '2', '10', '15']
+    >>> tracker.create_ticket(queue='helpdesk', \
+    ... subject='Coffee (important)', content='Help I Ran Out of Coffee!')
     19
-    >>> tracker.edit_ticket(19, Requestors='addicted@example.com')
-    True
-    >>> tracker.reply(19, text='Do you know Starbucks?')
+    >>> tracker.edit_ticket(19, Requestor='addicted@example.com')
     True
-    >>> tracker.logout()
+    >>> tracker.reply(19, content='Do you know Starbucks?')
     True
 
 Get the last important updates from a specific queue that have been updated recently::
 
-    >>> import rt
-    >>> tracker = rt.Rt('http://localhost/rt/REST/1.0/', http_auth=HTTPBasicAuth(os.environ['rtuser'], os.environ['rtpasswd']))
-    >>> tracker.default_queue = "my-queue"
+    >>> import datetime
+    >>> import base64
+    >>> import rt.rest2
+    >>> import requests.auth
+    >>> tracker = rt.rest2.Rt('http://localhost/rt/REST/2.0/', http_auth=requests.auth.HTTPBasicAuth('root', 'password'))
     >>> fifteen_minutes_ago = str(datetime.datetime.now() - datetime.timedelta(minutes=15))
     >>> tickets = tracker.last_updated(since=fifteen_minutes_ago)
     >>> for ticket in tickets:
-    >>>     id = ticket['id'][7:]
-    >>>     history = tracker.get_short_history(id)
-    >>>     last_update = list(reversed([h for h in history if h[1].startswith('Correspondence added') or h[1].startswith('Comments added')]))
-    >>>     hid = tracker.get_history(id, last_update[0][0] if last_update else history[0][0])[0]
-    >>>     print("http://localhost/rt/Ticket/Display.html?id=%s" % id)
-    >>>     print(hid['Content'])
-    >>>     print("\n")
-		
+    >>>     id = ticket['id']
+    >>>     history = tracker.get_ticket_history(id)
+    >>>     last_update = list(reversed([h for h in history if h['Type'] in ('Correspond', 'Comment')]))
+    >>>     hid = tracker.get_transaction(last_update[0]['id'] if last_update else history[0]['id'])
+    >>>
+    >>>     attachment_id = None
+    >>>     for k in hid['_hyperlinks']:
+    >>>         if k['ref'] == 'attachment':
+    >>>             attachment_id = k['_url'].rsplit('/', 1)[1]
+    >>>             break
+    >>>
+    >>>         if attachment_id is not None:
+    >>>             attachment = c.get_attachment(attachment_id)
+    >>>             if attachment['Content'] is not None:
+    >>>                 content = base64.b64decode(attachment['Content']).decode()
+    >>>                 print(content)
+
+
 		
 Please use docstrings to see how to use different functions. They are written
 in ReStructuredText. You can also generate HTML documentation by running
 ``make html`` in doc directory (Sphinx required).
 
-OFFICIAL SITE
+Official Site
 =============
 
 Project site, issue tracking and git repository:
diff --git a/doc/changelog.rst b/doc/changelog.rst
new file mode 100644
index 0000000..2d141de
--- /dev/null
+++ b/doc/changelog.rst
@@ -0,0 +1,54 @@
+Change Log
+==========
+
+Version 3.0.0 (2022-05-17)
+----------------------------
+The following is a major release of the `rt` library.
+There is support for the REST API version 1 as well as version 2.
+Please note that this release contains breaking changes and requires adaptations to existing code, even if you are
+sticking to version 1 of the API.
+These changes were necessary in order to properly support both API versions.
+
+Importing
+^^^^^^^^^
+Previously doing:
+
+    .. code-block:: python
+
+        import rt
+        c = rt.Rt(...)
+
+was enough to import the main class `Rt` as well as all exception classes.
+Starting with version 3, only the main exception class `RtError` is imported when importing the `rt` module.
+
+In order to continue using the API version 1 you need to explicitly import it from the `rest1` submodule:
+
+    .. code-block:: python
+
+        import rt.rest1
+        c = rt.rest1.Rt(...)
+
+If you need access to specific exception class, make sure to import the exceptions module:
+
+    .. code-block:: python
+
+        import rt.exceptions
+
+Everything else is the same as with version 2 of the library.
+
+.. WARNING::
+    The minimum supported version of python has been raised to 3.7.
+
+Exception classes
+^^^^^^^^^^^^^^^^^^
+Some exception classes were renamed to follow proper naming scheme (https://peps.python.org/pep-0008/#exception-names):
+
+.. csv-table::
+   :header: "<3.0.0", ">=3.0.0"
+   :widths: 15, 15
+
+    "NotAllowed", "NotAllowedError"
+    "UnexpectedResponse", "UnexpectedResponseError"
+    "UnexpectedMessageFormat", "UnexpectedMessageFormatError"
+    "InvalidUseError", "InvalidUseError"
+    "BadRequestError", "BadRequestError"
diff --git a/doc/conf.py b/doc/conf.py
index 4f550a8..8a8c68a 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -20,16 +20,23 @@
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.insert(0, os.path.abspath('.'))
+# sys.path.insert(0, os.path.abspath('.'))
 
 # -- General configuration -----------------------------------------------------
 
 # If your documentation needs a minimal Sphinx version, state it here.
-#needs_sphinx = '1.0'
+# needs_sphinx = '1.0'
 
 # Add any Sphinx extension module names here, as strings. They can be extensions
 # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
-extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx_copybutton']
+
+autodoc_default_options = {
+    'member-order': 'alphabetical',
+    'special-members': '__init__',
+    'undoc-members': True,
+    'exclude-members': '__weakref__'
+}
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
@@ -38,14 +45,14 @@
 source_suffix = '.rst'
 
 # The encoding of source files.
-#source_encoding = 'utf-8-sig'
+# source_encoding = 'utf-8-sig'
 
 # The master toctree document.
 master_doc = 'index'
 
 # General information about the project.
 project = u'rt'
-copyright = u'2013, Jiri Machalek'
+copyright = u'2013-present, A project founded by Jiri Machalek and maintained by the python-rt community.'
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
@@ -56,161 +63,160 @@
 # The short X.Y version.
 version = '.'.join(release.split('.')[:2])
 
-
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
-#language = None
+# language = None
 
 # There are two options for replacing |today|: either, you set today to some
 # non-false value, then it is used:
-#today = ''
+# today = ''
 # Else, today_fmt is used as the format for a strftime call.
-#today_fmt = '%B %d, %Y'
+# today_fmt = '%B %d, %Y'
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 exclude_patterns = ['_build']
 
 # The reST default role (used for this markup: `text`) to use for all documents.
-#default_role = None
+# default_role = None
 
 # If true, '()' will be appended to :func: etc. cross-reference text.
-#add_function_parentheses = True
+# add_function_parentheses = True
 
 # If true, the current module name will be prepended to all description
 # unit titles (such as .. function::).
-#add_module_names = True
+# add_module_names = True
 
 # If true, sectionauthor and moduleauthor directives will be shown in the
 # output. They are ignored by default.
-#show_authors = False
+# show_authors = False
 
 # The name of the Pygments (syntax highlighting) style to use.
 pygments_style = 'sphinx'
 
 # A list of ignored prefixes for module index sorting.
-#modindex_common_prefix = []
+# modindex_common_prefix = []
 
 
 # -- Options for HTML output ---------------------------------------------------
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
-html_theme = 'default'
+# html_theme = 'sphinx_rtd_theme'
+html_theme = 'furo'
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
-#html_theme_options = {}
+# html_theme_options = {}
 
 # Add any paths that contain custom themes here, relative to this directory.
-#html_theme_path = []
+# html_theme_path = []
 
 # The name for this set of Sphinx documents.  If None, it defaults to
 # "<project> v<release> documentation".
-#html_title = None
+# html_title = None
 
 # A shorter title for the navigation bar.  Default is the same as html_title.
-#html_short_title = None
+# html_short_title = None
 
 # The name of an image file (relative to this directory) to place at the top
 # of the sidebar.
-#html_logo = None
+# html_logo = None
 
 # The name of an image file (within the static path) to use as favicon of the
 # docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
 # pixels large.
-#html_favicon = None
+# html_favicon = None
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+# html_static_path = ['_static']
 
 # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
 # using the given strftime format.
-#html_last_updated_fmt = '%b %d, %Y'
+# html_last_updated_fmt = '%b %d, %Y'
 
 # If true, SmartyPants will be used to convert quotes and dashes to
 # typographically correct entities.
-#html_use_smartypants = True
+# html_use_smartypants = True
 
 # Custom sidebar templates, maps document names to template names.
-#html_sidebars = {}
+# html_sidebars = {}
 
 # Additional templates that should be rendered to pages, maps page names to
 # template names.
-#html_additional_pages = {}
+# html_additional_pages = {}
 
 # If false, no module index is generated.
-#html_domain_indices = True
+# html_domain_indices = True
 
 # If false, no index is generated.
-#html_use_index = True
+html_use_index = False
 
 # If true, the index is split into individual pages for each letter.
-#html_split_index = False
+# html_split_index = False
 
 # If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
+# html_show_sourcelink = True
 
 # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
-#html_show_sphinx = True
+# html_show_sphinx = True
 
 # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
-#html_show_copyright = True
+# html_show_copyright = True
 
 # If true, an OpenSearch description file will be output, and all pages will
 # contain a <link> tag referring to it.  The value of this option must be the
 # base URL from which the finished HTML is served.
-#html_use_opensearch = ''
+# html_use_opensearch = ''
 
 # This is the file name suffix for HTML files (e.g. ".xhtml").
-#html_file_suffix = None
+# html_file_suffix = None
 
 # Output file base name for HTML help builder.
 htmlhelp_basename = 'rtdoc'
 
-
 # -- Options for LaTeX output --------------------------------------------------
 
 latex_elements = {
-# The paper size ('letterpaper' or 'a4paper').
-#'papersize': 'letterpaper',
+    # The paper size ('letterpaper' or 'a4paper').
+    # 'papersize': 'letterpaper',
 
-# The font size ('10pt', '11pt' or '12pt').
-#'pointsize': '10pt',
+    # The font size ('10pt', '11pt' or '12pt').
+    # 'pointsize': '10pt',
 
-# Additional stuff for the LaTeX preamble.
-#'preamble': '',
+    # Additional stuff for the LaTeX preamble.
+    # 'preamble': '',
 }
 
 # Grouping the document tree into LaTeX files. List of tuples
 # (source start file, target name, title, author, documentclass [howto/manual]).
 latex_documents = [
-  ('index', 'rt.tex', u'rt Documentation',
-   u'Jiri Machalek', 'manual'),
+    ('index', 'rt.tex', u'rt Documentation',
+     u'Jiri Machalek', 'manual'),
 ]
 
 # The name of an image file (relative to this directory) to place at the top of
 # the title page.
-#latex_logo = None
+# latex_logo = None
 
 # For "manual" documents, if this is true, then toplevel headings are parts,
 # not chapters.
-#latex_use_parts = False
+# latex_use_parts = False
 
 # If true, show page references after internal links.
-#latex_show_pagerefs = False
+# latex_show_pagerefs = False
 
 # If true, show URL addresses after external links.
-#latex_show_urls = False
+# latex_show_urls = False
 
 # Documents to append as an appendix to all manuals.
-#latex_appendices = []
+# latex_appendices = []
 
 # If false, no module index is generated.
-#latex_domain_indices = True
+# latex_domain_indices = True
 
 
 # -- Options for manual page output --------------------------------------------
@@ -223,7 +229,7 @@
 ]
 
 # If true, show URL addresses after external links.
-#man_show_urls = False
+# man_show_urls = False
 
 
 # -- Options for Texinfo output ------------------------------------------------
@@ -232,16 +238,16 @@
 # (source start file, target name, title, author,
 #  dir menu entry, description, category)
 texinfo_documents = [
-  ('index', 'rt', u'rt Documentation',
-   u'Jiri Machalek', 'rt', 'One line description of project.',
-   'Miscellaneous'),
+    ('index', 'rt', u'rt Documentation',
+     u'Jiri Machalek', 'rt', 'One line description of project.',
+     'Miscellaneous'),
 ]
 
 # Documents to append as an appendix to all manuals.
-#texinfo_appendices = []
+# texinfo_appendices = []
 
 # If false, no module index is generated.
-#texinfo_domain_indices = True
+# texinfo_domain_indices = True
 
 # How to display URL addresses: 'footnote', 'no', or 'inline'.
-#texinfo_show_urls = 'footnote'
+# texinfo_show_urls = 'footnote'
diff --git a/doc/exceptions.rst b/doc/exceptions.rst
new file mode 100644
index 0000000..f3d79d1
--- /dev/null
+++ b/doc/exceptions.rst
@@ -0,0 +1,5 @@
+Exceptions
+==============================
+
+.. automodule:: rt.exceptions
+   :members:
diff --git a/doc/index.rst b/doc/index.rst
index 451c4a6..4e558a3 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -9,26 +9,39 @@ Welcome to rt's documentation!
 Contents:
 
 .. toctree::
-   :maxdepth: 2
+   :maxdepth: 1
 
+   rest1
+   rest2
+   usage
+   changelog
+   exceptions
    glossary
 
-.. automodule:: rt.rt
-   :members:
+.. csv-table:: Python version compatibility:
+   :header: "Python", "rt"
+   :widths: 15, 15
 
-.. automodule:: rt.exceptions
-   :members:
+   "2.7", "< 2.0.0"
+   ">= 3.5, <3.7", ">= 2.0.0, < 3.0.0"
+   ">= 3.7", ">= 3.0.0"
 
-Get rt module
-=============
+.. note:: Please note that starting with the major release of v3.0.0, this library requires Python version >= 3.7.
+    See the *Python version compatibility* table above for more detailed information.
+
+.. warning:: Although version 3.x still supports RT REST API version 1, it contains minor breaking changes.
+    Please see the :doc:`changelog` in the documentation for details.
+
+Get the rt module
+=================
 
 Using pip::
-    
-    $ pip install rt
+
+    pip install rt
 
 Using project git repository::
     
-    $ git clone https://github.com/python-rt/python-rt
+    git clone https://github.com/python-rt/python-rt
 
 Indices and tables
 ==================
diff --git a/doc/rest1.rst b/doc/rest1.rst
new file mode 100644
index 0000000..6868a5b
--- /dev/null
+++ b/doc/rest1.rst
@@ -0,0 +1,5 @@
+REST1
+==============================
+
+.. automodule:: rt.rest1
+   :members:
diff --git a/doc/rest2.rst b/doc/rest2.rst
new file mode 100644
index 0000000..7dadc81
--- /dev/null
+++ b/doc/rest2.rst
@@ -0,0 +1,5 @@
+REST2
+==============================
+
+.. automodule:: rt.rest2
+   :members:
diff --git a/doc/usage.rst b/doc/usage.rst
new file mode 100644
index 0000000..0303ec7
--- /dev/null
+++ b/doc/usage.rst
@@ -0,0 +1,258 @@
+Usage
+=====
+
+REST API version 2
+-------------------
+
+Creating a Connection
+`````````````````````
+::
+
+    import rt.rest2
+
+    api_url = 'http://localhost:8080/REST/2.0/'
+    username = 'root'
+    password = 'password'
+    c = rt.rest2.Rt(url=baseurl, http_auth=requests.auth.HTTPBasicAuth('root', 'password'))
+
+
+Ticket Operations
+`````````````````
+
+Fetching a ticket::
+
+    c.get_ticket(1)
+
+which gives:
+
+.. code-block:: json
+
+    {
+        "Due": "1970-01-01T00:00:00Z",
+        "Status": "new",
+        "Created": "2022-05-02T18:54:36Z",
+        "CustomFields": [],
+        "TimeEstimated": "0",
+        "LastUpdatedBy": {
+            "id": "root",
+            "type": "user",
+            "_url": "http://localhost:8080/REST/2.0/user/root"
+        },
+        "Type": "ticket",
+        "Owner": {
+            "id": "Nobody",
+            "_url": "http://localhost:8080/REST/2.0/user/Nobody",
+            "type": "user"
+        },
+        "Cc": [],
+        "Started": "1970-01-01T00:00:00Z",
+        "AdminCc": [],
+        "Priority": "0",
+        "LastUpdated": "2022-05-02T19:21:30Z",
+        "Subject": "Testing issue wvbocycTSwmNTAX",
+        "FinalPriority": "0",
+        "Queue": {
+            "id": "1",
+            "type": "queue",
+            "_url": "http://localhost:8080/REST/2.0/queue/1",
+            "Name": "General"
+        },
+        "InitialPriority": "0",
+        "Resolved": "1970-01-01T00:00:00Z",
+        "Creator": {
+            "type": "user",
+            "_url": "http://localhost:8080/REST/2.0/user/root",
+            "id": "root"
+        },
+        "EffectiveId": {
+            "_url": "http://localhost:8080/REST/2.0/ticket/8",
+            "type": "ticket",
+            "id": "8"
+        },
+        "Starts": "1970-01-01T00:00:00Z",
+        "TimeWorked": "0",
+        "TimeLeft": "0",
+        "Requestor": [],
+        "_hyperlinks": [
+            {
+                "id": 8,
+                "ref": "self",
+                "_url": "http://localhost:8080/REST/2.0/ticket/8",
+                "type": "ticket"
+            },
+            {
+                "ref": "history",
+                "_url": "http://localhost:8080/REST/2.0/ticket/8/history"
+            },
+            {
+                "_url": "http://localhost:8080/REST/2.0/ticket/8/correspond",
+                "ref": "correspond"
+            },
+            {
+                "ref": "comment",
+                "_url": "http://localhost:8080/REST/2.0/ticket/8/comment"
+            },
+            {
+                "ref": "lifecycle",
+                "update": "Respond",
+                "from": "new",
+                "label": "Open It",
+                "_url": "http://localhost:8080/REST/2.0/ticket/8/correspond",
+                "to": "open"
+            },
+            {
+                "label": "Resolve",
+                "to": "resolved",
+                "_url": "http://localhost:8080/REST/2.0/ticket/8/comment",
+                "ref": "lifecycle",
+                "update": "Comment",
+                "from": "new"
+            },
+            {
+                "to": "rejected",
+                "_url": "http://localhost:8080/REST/2.0/ticket/8/correspond",
+                "label": "Reject",
+                "from": "new",
+                "update": "Respond",
+                "ref": "lifecycle"
+            },
+            {
+                "ref": "lifecycle",
+                "label": "Delete",
+                "_url": "http://localhost:8080/REST/2.0/ticket/8",
+                "from": "new",
+                "to": "deleted"
+            }
+        ],
+        "id": 8
+    }
+
+
+Getting ticket links::
+
+    c.get_links(1)
+
+for a ticket with #1 having ticket #7 as parent, this would have as result:
+
+.. code-block:: json
+
+    [
+        {
+            "_url": "http://localhost:8080/REST/2.0/ticket/7",
+            "type": "ticket",
+            "ref": "parent",
+            "id": "7"
+        }
+    ]
+
+Editing ticket links. Adding a dependency on another ticket::
+
+    c.edit_link(1, 'DependsOn', 7, delete=False)
+
+Creating a ticket::
+
+    new_ticket = {'Requestor': ['test@example.com'],
+                  }
+    res = c.create_ticket('General',
+                          subject='Test subject',
+                          content='Ticket body...',
+                          **new_ticket
+                          )
+
+This returns the ID of the created ticket.
+
+Editing a ticket::
+
+    c.edit_ticket(8,
+                  Subject='Re: Test subject',
+                  CustomFields={'CF1': 'value1',
+                                ...
+                                }
+                  )
+
+
+Searching for tickets with status *NEW* in the *General* queue::
+
+    c.search(Queue='SOC', raw_query='''Status = 'NEW' ''', Format='i')
+
+gives:
+
+.. code-block:: json
+
+    [
+        {
+            "type": "ticket",
+            "InitialPriority": "0",
+            "CustomFields": "",
+            "TimeEstimated": "0",
+            "Due": "1970-01-01T00:00:00Z",
+            "Priority": "0",
+            "Status": "new",
+            "Created": "2022-05-02T18:54:35Z",
+            "Queue": {
+                "Name": "General",
+                "type": "queue",
+                "_url": "http://localhost:8080/REST/2.0/queue/1",
+                "id": "1"
+            },
+            "Subject": "Testing issue SsOwRvDXMGnurhU",
+            "LastUpdated": "2022-05-02T20:44:02Z",
+            "TimeLeft": "0",
+            "Owner": {
+                "id": "Nobody",
+                "_url": "http://localhost:8080/REST/2.0/user/Nobody",
+                "type": "user"
+            },
+            "Started": "1970-01-01T00:00:00Z",
+            "Requestor": [],
+            "Cc": [],
+            "AdminCc": [],
+            "id": "7",
+            "_url": "http://localhost:8080/REST/2.0/ticket/7",
+            "Type": "ticket"
+        },
+        ...
+    ]
+
+Do a reply on a ticket::
+
+    c.reply(1, content='test')
+
+Comment on a ticket::
+
+    c.comment(1, content='test')
+
+Merge ticket #1 into #2::
+
+    c.merge_ticket(1, 2)
+
+Comment on a ticket and add an attachment::
+
+    attachments = []
+    with open('README.rst', 'rb') as fhdl:
+        attachments.append(rt.rest2.Attachment('README.rst', 'test/plain', fhdl.read()))
+    print(json.dumps(c.comment(1, 'test', attachments=attachments), indent=4))
+
+
+Get attachments for a ticket::
+
+    c.get_attachments(1)
+
+returns:
+
+.. code-block:: json
+
+    [
+        {
+            "type": "attachment",
+            "_url": "http://localhost:8080/REST/2.0/attachment/34",
+            "Filename": "README.rst",
+            "ContentType": "test/plain",
+            "id": "34",
+            "ContentLength": "3578"
+        }
+    ]
+
+Fetch an attachment by its ID::
+
+    c.get_attachment(34)
diff --git a/rt/__init__.py b/rt/__init__.py
index 6e2feb9..acb95c4 100644
--- a/rt/__init__.py
+++ b/rt/__init__.py
@@ -1,2 +1,5 @@
-from .rt import *
-from .exceptions import *
+"""Module entry point."""
+
+from .exceptions import RtError
+
+__all__ = ['RtError']
diff --git a/rt/exceptions.py b/rt/exceptions.py
index 99ace4f..db66309 100644
--- a/rt/exceptions.py
+++ b/rt/exceptions.py
@@ -1,5 +1,7 @@
 """Exceptions collection for the rt library."""
 
+import typing
+
 
 class RtError(Exception):
     """ Super class of all Rt Errors """
@@ -10,30 +12,40 @@ class AuthorizationError(RtError):
     or missing credentials. """
 
 
-class NotAllowed(RtError):
+class NotAllowedError(RtError):
     """ Exception raised when request cannot be finished due to
     insufficient privileges. """
 
 
-class UnexpectedResponse(RtError):
+class UnexpectedResponseError(RtError):
     """ Exception raised when unexpected HTTP code is received. """
 
+    def __init__(self, message: str, status_code: typing.Optional[int] = None, response_message: typing.Optional[str] = None) -> None:
+        """ Initialization."""
+        super().__init__(message)
+        self.status_code = status_code
+        self.response_message = response_message
+
 
-class UnexpectedMessageFormat(RtError):
+class UnexpectedMessageFormatError(RtError):
     """ Exception raised when response has bad status code (not the HTTP code,
     but code in the first line of the body as 200 in `RT/4.0.7 200 Ok`)
     or message parsing fails because of unexpected format. """
 
 
+class NotFoundError(RtError):
+    """Exception raised if requested resource is not found."""
+
+
 class APISyntaxError(RtError):
     """ Exception raised when syntax error is received. """
 
 
-class InvalidUse(RtError):
+class InvalidUseError(RtError):
     """ Exception raised when API method is not used correctly. """
 
 
-class BadRequest(RtError):
+class BadRequestError(RtError):
     """ Exception raised when HTTP code 400 (Bad Request) is received. """
 
 
@@ -46,7 +58,7 @@ def __init__(self, message: str, cause: Exception) -> None:
         :keyword message: Exception details
         :keyword cause: Cause exception
         """
-        super().__init__(message + ' (Caused by ' + repr(cause) + ")")
+        super().__init__(f'{message} (Caused by {repr(cause)})')
         self.cause = cause
 
 
diff --git a/rt/rt.py b/rt/rest1.py
similarity index 89%
rename from rt/rt.py
rename to rt/rest1.py
index 8ff66e0..64c3c37 100644
--- a/rt/rt.py
+++ b/rt/rest1.py
@@ -35,7 +35,7 @@
 import requests
 import requests.auth
 
-from .exceptions import NotAllowed, UnexpectedResponse, UnexpectedMessageFormat, InvalidUse, BadRequest, AuthorizationError, APISyntaxError, InvalidQueryError
+from .exceptions import NotAllowedError, UnexpectedResponseError, UnexpectedMessageFormatError, InvalidUseError, BadRequestError, AuthorizationError, APISyntaxError, InvalidQueryError
 
 __license__ = """ Copyright (C) 2012 CZ.NIC, z.s.p.o.
     Copyright (c) 2015 Genome Research Ltd.
@@ -68,7 +68,7 @@
 
 
 class Rt:
-    """ :term:`API` for Request Tracker according to
+    r""" :term:`API` for Request Tracker according to
     http://requesttracker.wikia.com/wiki/REST. Interface is based on
     :term:`REST` architecture, which is based on HTTP/1.1 protocol. This module
     is therefore mainly sending and parsing special HTTP messages.
@@ -166,7 +166,7 @@ def __request(self,
                   files: typing.Optional[typing.List[typing.Tuple[str, typing.IO, typing.Optional[str]]]] = None,
                   without_login: bool = False,
                   text_response: bool = True) -> str:
-        """ General request for :term:`API`.
+        r""" General request for :term:`API`.
 
         :keyword selector: End part of URL which completes self.url parameter
                            set during class initialization.
@@ -198,8 +198,8 @@ def __request(self,
             else:
                 files_data = {}
                 for i, file_pair in enumerate(files):
-                    files_data['attachment_{:d}'.format(i + 1)] = file_pair
-                response = self.session.post(url, data=post_data, files=files_data)
+                    files_data[f'attachment_{i + 1}'] = file_pair
+                response = self.session.post(url, data=post_data, files=files_data)  # type: ignore
 
             method = "GET"
             if post_data or files:
@@ -214,7 +214,7 @@ def __request(self,
             if response.status_code == 401:
                 raise AuthorizationError('Server could not verify that you are authorized to access the requested document.')
             if response.status_code != 200:
-                raise UnexpectedResponse('Received status code {:d} instead of 200.'.format(response.status_code))
+                raise UnexpectedResponseError(f'Received status code {response.status_code} instead of 200.')
             try:
                 if response.encoding:
                     result = response.content.decode(response.encoding.lower())
@@ -222,10 +222,10 @@ def __request(self,
                     # try utf-8 if encoding is not filled
                     result = response.content.decode('utf-8')
             except LookupError as exc:
-                raise UnexpectedResponse('Unknown response encoding: {}.'.format(response.encoding)) from exc
+                raise UnexpectedResponseError(f'Unknown response encoding: {response.encoding}.') from exc
             except UnicodeError as exc:
                 if text_response:
-                    raise UnexpectedResponse('Unknown response encoding (UTF-8 does not work).') from exc
+                    raise UnexpectedResponseError('Unknown response encoding (UTF-8 does not work).') from exc
 
                 # replace errors - we need decoded content just to check for error codes in __check_response
                 result = response.content.decode('utf-8', 'replace')
@@ -246,14 +246,14 @@ def __get_status_code(msg: str) -> typing.Optional[int]:
         """
         try:
             return int(msg.split('\n')[0].split(' ')[1])
-        except Exception:
+        except (ValueError, AttributeError, LookupError):
             return None
 
     def __check_response(self, msg: typing.Union[str, list]) -> None:
         """ Search general errors in server response and raise exceptions when found.
 
         :keyword msg: Result message
-        :raises NotAllowed: Exception raised when operation was called with
+        :raises NotAllowedError: Exception raised when operation was called with
                             insufficient privileges
         :raises AuthorizationError: Credentials are invalid or missing
         :raises APISyntaxError: Syntax error
@@ -261,13 +261,13 @@ def __check_response(self, msg: typing.Union[str, list]) -> None:
         if not isinstance(msg, list):
             msg = msg.split("\n")
         if (len(msg) > 2) and self.RE_PATTERNS['not_allowed_pattern'].match(msg[2]):
-            raise NotAllowed(msg[2][2:])
+            raise NotAllowedError(msg[2][2:])
         if self.RE_PATTERNS['credentials_required_pattern'].match(msg[0]):
             raise AuthorizationError('Credentials required.')
         if self.RE_PATTERNS['syntax_error_pattern'].match(msg[0]):
             raise APISyntaxError(msg[2][2:] if len(msg) > 2 else 'Syntax error.')
         if self.RE_PATTERNS['bad_request_pattern'].match(msg[0]):
-            raise BadRequest(msg[3] if len(msg) > 2 else 'Bad request.')
+            raise BadRequestError(msg[3] if len(msg) > 2 else 'Bad request.')
 
     @staticmethod
     def __normalize_list(msg: typing.Union[str, typing.Sequence[str]]) -> typing.Sequence[str]:
@@ -296,7 +296,7 @@ def __parse_response_dict(cls,
           the RT API response body.
         :keyword expect_keys: An iterable of strings. If any of these strings
           do not appear in the response, raise an error.
-        :raises UnexpectedMessageFormat: The body did not follow the expected format,
+        :raises UnexpectedMessageFormatError: The body did not follow the expected format,
           or an expected key was missing
         :returns: Dictionary mapping field names to value strings
         :rtype: Dictionary with string keys and string values
@@ -315,7 +315,7 @@ def __parse_response_dict(cls,
                 try:
                     fields[key].append(line.lstrip())
                 except KeyError:
-                    raise UnexpectedMessageFormat(
+                    raise UnexpectedMessageFormatError(
                             "Response has a continuation line with no field to continue",
                     ) from None
             else:
@@ -331,14 +331,10 @@ def __parse_response_dict(cls,
                     key = line[:-1]
                     fields[key] = []
                 else:
-                    raise UnexpectedMessageFormat(
-                            "Response has a line without a field name: {!r}".format(line),
-                    )
+                    raise UnexpectedMessageFormatError(f"Response has a line without a field name: {line}")
         for key in expect_keys:
             if key not in fields:
-                raise UnexpectedMessageFormat(
-                        "Missing line starting with `{}:`.".format(key),
-                )
+                raise UnexpectedMessageFormatError(f"Missing line starting with `{key}:`.")
         return {key: '\n'.join(lines) for key, lines in fields.items()}
 
     @classmethod
@@ -352,7 +348,7 @@ def __parse_response_numlist(cls, msg: typing.Iterable[str],
 
         :keyword msg: A multiline string, or an iterable of string lines, with
           the RT API response body.
-        :raises UnexpectedMessageFormat: The body did not follow the expected format
+        :raises UnexpectedMessageFormatError: The body did not follow the expected format
         :returns: List of 2-tuples with ids and descriptions
         :rtype: List of 2-tuples (int, str)
         """
@@ -367,7 +363,7 @@ def __parse_response_ticket(cls, msg: typing.Iterable[str]) -> typing.Dict[str,
 
         :keyword msg: A multiline string, or an iterable of string lines, with
           the RT API response body.
-        :raises UnexpectedMessageFormat: The body did not follow the expected format
+        :raises UnexpectedMessageFormatError: The body did not follow the expected format
         :returns: Dictionary mapping field names to value strings, or lists of
           strings for the People fields Requestors, Cc, and AdminCc
         :rtype: Dictionary with string keys and values that are strings or lists
@@ -375,7 +371,7 @@ def __parse_response_ticket(cls, msg: typing.Iterable[str]) -> typing.Dict[str,
         """
         pairs = cls.__parse_response_dict(msg, ['Requestors'])
         if not pairs.get('id', '').startswith('ticket/'):
-            raise UnexpectedMessageFormat('Response from RT didn\'t contain a valid ticket_id')
+            raise UnexpectedMessageFormatError('Response from RT didn\'t contain a valid ticket_id')
         _, _, numerical_id = pairs['id'].partition('/')
         ticket = typing.cast(typing.Dict[str, typing.Sequence[str]], pairs)
         ticket['numerical_id'] = numerical_id
@@ -471,7 +467,7 @@ def last_updated(self, since: str, queue: typing.Optional[typing.Union[str, obje
     def search(self, Queue: typing.Optional[typing.Union[str, object]] = None, order: typing.Optional[str] = None,
                raw_query: typing.Optional[str] = None, Format: str = 'l', Fields: typing.Optional[typing.List[str]] = None,
                **kwargs: typing.Any) -> typing.List[dict]:
-        """ Search arbitrary needles in given fields and queue.
+        r""" Search arbitrary needles in given fields and queue.
 
         Example::
 
@@ -523,14 +519,14 @@ def search(self, Queue: typing.Optional[typing.Union[str, object]] = None, order
 
         :returns: List of matching tickets. Each ticket is the same dictionary
                   as in :py:meth:`~Rt.get_ticket`\\.
-        :raises:  UnexpectedMessageFormat: Unexpected format of returned message.
+        :raises:  UnexpectedMessageFormatError: Unexpected format of returned message.
                   InvalidQueryError: If raw query is malformed
         """
         get_params = {}
         query = []
         url = 'search/ticket'
         if Queue is not ALL_QUEUES:
-            query.append("Queue=\'{}\'".format(Queue or self.default_queue))
+            query.append(f"Queue=\'{Queue or self.default_queue}\'")
         if not raw_query:
             operators_map = {
                 'gt': '>',
@@ -550,9 +546,9 @@ def search(self, Queue: typing.Optional[typing.Union[str, object]] = None, order
                     key = '__'.join(key_parts[:-1])
                     op = operators_map.get(key_parts[-1], '=')
                 if key[:3] != 'CF_':
-                    query.append("{}{}\'{}\'".format(key, op, value))
+                    query.append(f"{key}{op}\'{value}\'")
                 else:
-                    query.append("'CF.{{{}}}'{}\'{}\'".format(key[3:], op, value))
+                    query.append(f"'CF.{{{key[3:]}}}'{op}\'{value}\'")
         else:
             query.append(raw_query)
         get_params['query'] = ' AND '.join('(' + part + ')' for part in query)
@@ -623,9 +619,9 @@ def get_ticket(self, ticket_id: typing.Union[str, int]) -> typing.Optional[dict]
                       * TimeEstimated
                       * TimeWorked
                       * TimeLeft
-        :raises UnexpectedMessageFormat: Unexpected format of returned message.
+        :raises UnexpectedMessageFormatError: Unexpected format of returned message.
         """
-        _msg = self.__request('ticket/{}/show'.format(str(ticket_id), ))
+        _msg = self.__request(f'ticket/{ticket_id}/show')
         status_code = self.__get_status_code(_msg)
         if status_code is not None and status_code == 200:
             msg = _msg.split('\n')
@@ -639,7 +635,7 @@ def get_ticket(self, ticket_id: typing.Union[str, int]) -> typing.Optional[dict]
 
             return self.__parse_response_ticket(msg)
 
-        raise UnexpectedMessageFormat('Received status code is {} instead of 200.'.format(status_code))
+        raise UnexpectedMessageFormatError(f'Received status code is {status_code} instead of 200.')
 
     @staticmethod
     def __ticket_post_data(data_source: dict) -> str:
@@ -652,14 +648,14 @@ def __ticket_post_data(data_source: dict) -> str:
         post_data = []
         for key in data_source:
             if key.startswith('CF_'):
-                rt_key = 'CF.{{{}}}'.format(key[3:])
+                rt_key = f'CF.{{{key[3:]}}}'
             else:
                 rt_key = key
             value = data_source[key]
             if isinstance(value, (list, tuple)):
                 value = ', '.join(value)
             value_lines = iter(value.splitlines())
-            post_data.append('{}: {}'.format(rt_key, next(value_lines, '')))
+            post_data.append(f'''{rt_key}: {next(value_lines, '')}''')
             post_data.extend(' ' + line for line in value_lines)
         return '\n'.join(post_data)
 
@@ -712,7 +708,7 @@ def create_ticket(self, Queue: typing.Optional[typing.Union[str, object]] = None
 
         if files:
             for file_info in files:
-                post_data += "\nAttachment: {}".format(file_info[0], )
+                post_data += f"\nAttachment: {file_info[0]}"
 
         msg = self.__request('ticket/new', post_data={'content': post_data}, files=files)
         for line in msg.split('\n')[2:-1]:
@@ -742,7 +738,7 @@ def edit_ticket(self, ticket_id: typing.Union[str, int], **kwargs: typing.Any) -
                       was set (in this case all other valid fields are changed)
         """
         post_data = self.__ticket_post_data(kwargs)
-        msg = self.__request('ticket/{}/edit'.format(str(ticket_id)), post_data={'content': post_data})
+        msg = self.__request(f'ticket/{ticket_id}/edit', post_data={'content': post_data})
         if "" == msg:  # Ticket not modified
             return True
         state = msg.split('\n')[2]
@@ -767,14 +763,14 @@ def get_history(self, ticket_id: typing.Union[str, int],
                   of pairs (attachment_id,filename_with_size).
 
                   Returns None if ticket or transaction does not exist.
-        :raises UnexpectedMessageFormat: Unexpected format of returned message.
+        :raises UnexpectedMessageFormatError: Unexpected format of returned message.
         """
         if transaction_id is None:
             # We are using "long" format to get all history items at once.
             # Each history item is then separated by double dash.
-            msgs = self.__request('ticket/{}/history?format=l'.format(str(ticket_id), ))
+            msgs = self.__request(f'ticket/{ticket_id}/history?format=l')
         else:
-            msgs = self.__request('ticket/{}/history/id/{}'.format(str(ticket_id), str(transaction_id)))
+            msgs = self.__request(f'ticket/{ticket_id}/history/id/{transaction_id}')
         lines = msgs.split('\n')
         if (len(lines) > 2) and (
                 self.RE_PATTERNS['does_not_exist_pattern'].match(lines[2]) or self.RE_PATTERNS['not_related_pattern'].match(
@@ -798,7 +794,7 @@ def get_short_history(self, ticket_id: typing.Union[str, int]) -> typing.Optiona
                   Each history item is a tuple containing (id, Description).
                   Returns None if ticket does not exist.
         """
-        msg = self.__request('ticket/{}/history'.format(str(ticket_id), ))
+        msg = self.__request(f'ticket/{ticket_id}/history')
         lines = msg.split('\n')
         if self.__get_status_code(lines[0]) == 200:
             if (len(lines) > 2) and self.RE_PATTERNS['does_not_exist_pattern'].match(lines[2]):
@@ -828,7 +824,7 @@ def __correspond(self,
                       Operation was successful
                   ``False``
                       Sending failed (status code != 200)
-        :raises BadRequest: When ticket does not exist
+        :raises BadRequestError: When ticket does not exist
         """
         post_data = {'content': """id: {}
 Action: {}
@@ -839,8 +835,8 @@ def __correspond(self,
 
         if files:
             for file_info in files:
-                post_data['content'] += "\nAttachment: {}".format(file_info[0], )
-        msg = self.__request('ticket/{}/comment'.format(str(ticket_id), ),
+                post_data['content'] += f"\nAttachment: {file_info[0]}"
+        msg = self.__request(f'ticket/{ticket_id}/comment',
                              post_data=post_data, files=files)
         return self.__get_status_code(msg) == 200
 
@@ -872,7 +868,7 @@ def reply(self, ticket_id: typing.Union[str, int], text: str = '', cc: str = '',
                       Operation was successful
                   ``False``
                       Sending failed (status code != 200)
-        :raises BadRequest: When ticket does not exist
+        :raises BadRequestError: When ticket does not exist
         """
         return self.__correspond(ticket_id, text, 'correspond', cc, bcc, content_type, files)
 
@@ -909,7 +905,7 @@ def comment(self, ticket_id: typing.Union[str, int], text: str = '', cc: str = '
                       Operation was successful
                   ``False``
                       Sending failed (status code != 200)
-        :raises BadRequest: When ticket does not exist
+        :raises BadRequestError: When ticket does not exist
         """
         return self.__correspond(ticket_id, text, 'comment', cc, bcc, content_type, files)
 
@@ -921,7 +917,7 @@ def get_attachments(self, ticket_id: typing.Union[str, int]) -> typing.Optional[
                   Tuple format: (id, name, content_type, size)
                   Returns None if ticket does not exist.
         """
-        msg = self.__request('ticket/{}/attachments'.format(str(ticket_id), ))
+        msg = self.__request(f'ticket/{ticket_id}/attachments')
         lines = msg.split('\n')
         if (len(lines) > 2) and self.RE_PATTERNS['does_not_exist_pattern'].match(lines[2]):
             return None
@@ -997,9 +993,9 @@ def get_attachment(self, ticket_id: typing.Union[str, int], attachment_id: typin
                   emails not on Request Tracker!
 
                   Returns None if ticket or attachment does not exist.
-        :raises UnexpectedMessageFormat: Unexpected format of returned message.
+        :raises UnexpectedMessageFormatError: Unexpected format of returned message.
         """
-        _msg = self.__request('ticket/{}/attachments/{}'.format(str(ticket_id), str(attachment_id)),
+        _msg = self.__request(f'ticket/{ticket_id}/attachments/{attachment_id}',
                               text_response=False)
         msg = typing.cast(bytes, _msg).split(b'\n')
         if (len(msg) > 2) and (
@@ -1009,15 +1005,15 @@ def get_attachment(self, ticket_id: typing.Union[str, int], attachment_id: typin
         head_matching = [i for i, m in enumerate(msg) if self.RE_PATTERNS['headers_pattern_bytes'].match(m)]
         head_id = head_matching[0] if head_matching else None
         if not head_id:
-            raise UnexpectedMessageFormat('Unexpected headers part of attachment entry. \
+            raise UnexpectedMessageFormatError('Unexpected headers part of attachment entry. \
                                            Missing line starting with `Headers:`.')
         msg[head_id] = re.sub(b'^Headers: (.*)$', rb'\1', msg[head_id])
         cont_matching = [i for i, m in enumerate(msg) if self.RE_PATTERNS['content_pattern_bytes'].match(m)]
         if not cont_matching:
-            raise UnexpectedMessageFormat('Unexpected content part of attachment entry. \
+            raise UnexpectedMessageFormatError('Unexpected content part of attachment entry. \
                                            Missing line starting with `Content:`.')
         cont_id = cont_matching[0]
-        pairs = {}  # type: typing.Dict[str, typing.Any]
+        pairs: typing.Dict[str, typing.Any] = {}
         for i in range(head_id):
             if b': ' in msg[i]:
                 header, content = msg[i].split(b': ', 1)
@@ -1037,7 +1033,7 @@ def get_attachment(self, ticket_id: typing.Union[str, int], attachment_id: typin
 
     def get_attachment_content(self, ticket_id: typing.Union[str, int], attachment_id: typing.Union[str, int]) -> \
             typing.Optional[bytes]:
-        """ Get content of attachment without headers.
+        r""" Get content of attachment without headers.
 
         This function is necessary to use for binary attachment,
         as it can contain ``\\n`` chars, which would disrupt parsing
@@ -1053,8 +1049,7 @@ def get_attachment_content(self, ticket_id: typing.Union[str, int], attachment_i
         Returns: Bytes with content of attachment or None if ticket or
                  attachment does not exist.
         """
-        msg = typing.cast(bytes, self.__request('ticket/{}/attachments/{}/content'.format
-                                                (str(ticket_id), str(attachment_id)),
+        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 (
@@ -1091,9 +1086,9 @@ def get_user(self, user_id: typing.Union[str, int]) -> typing.Optional[typing.Di
                       * Name
 
                   None is returned if user does not exist.
-        :raises UnexpectedMessageFormat: In case that returned status code is not 200
+        :raises UnexpectedMessageFormatError: In case that returned status code is not 200
         """
-        msg = self.__request('user/{}'.format(str(user_id), ))
+        msg = self.__request(f'user/{user_id}')
         lines = msg.split('\n')
         status_code = self.__get_status_code(lines[0])
         if status_code is not None and status_code == 200:
@@ -1101,7 +1096,7 @@ def get_user(self, user_id: typing.Union[str, int]) -> typing.Optional[typing.Di
                 return None
             return self.__parse_response_dict(lines)
 
-        raise UnexpectedMessageFormat('Received status code is {} instead of 200.'.format(status_code))
+        raise UnexpectedMessageFormatError(f'Received status code is {status_code} instead of 200.')
 
     def create_user(self, Name: str, EmailAddress: str, **kwargs: typing.Any) -> typing.Union[int, bool]:
         """ Create user (undocumented API feature).
@@ -1110,8 +1105,8 @@ def create_user(self, Name: str, EmailAddress: str, **kwargs: typing.Any) -> typ
         :param EmailAddress: Email address (required)
         :param kwargs: Optional fields to set (see edit_user)
         :returns: ID of new user or False when create fails
-        :raises BadRequest: When user already exists
-        :raises InvalidUse: When invalid fields are set
+        :raises BadRequestError: When user already exists
+        :raises InvalidUseError: When invalid fields are set
         """
         return self.edit_user('new', Name=Name, EmailAddress=EmailAddress, **kwargs)
 
@@ -1153,8 +1148,8 @@ def edit_user(self, user_id: typing.Union[str, int], **kwargs: typing.Any) -> ty
                           * Disabled
 
         :returns: ID of edited user or False when edit fails
-        :raises BadRequest: When user does not exist
-        :raises InvalidUse: When invalid fields are set
+        :raises BadRequestError: When user does not exist
+        :raises InvalidUseError: When invalid fields are set
         """
         valid_fields = {'name', 'password', 'emailaddress', 'realname',
                         'nickname', 'gecos', 'organization', 'address1', 'address2',
@@ -1167,10 +1162,10 @@ def edit_user(self, user_id: typing.Union[str, int], **kwargs: typing.Any) -> ty
 
         if not used_fields <= valid_fields:
             invalid_fields = ", ".join(list(used_fields - valid_fields))
-            raise InvalidUse("Unsupported names of fields: {}.".format(invalid_fields))
-        post_data = 'id: user/{}\n'.format(str(user_id))
+            raise InvalidUseError(f"Unsupported names of fields: {invalid_fields}.")
+        post_data = f'id: user/{user_id}\n'
         for key, val in kwargs.items():
-            post_data += '{}: {}\n'.format(key, val)
+            post_data += f'{key}: {val}\n'
         msg = self.__request('edit', post_data={'content': post_data})
         msgs = msg.split('\n')
         if (self.__get_status_code(msg) == 200) and (len(msgs) > 2):
@@ -1196,9 +1191,9 @@ def get_queue(self, queue_id: typing.Union[str, int]) -> typing.Optional[typing.
                       * FinalPriority
                       * DefaultDueIn
 
-        :raises UnexpectedMessageFormat: In case that returned status code is not 200
+        :raises UnexpectedMessageFormatError: In case that returned status code is not 200
         """
-        msg = self.__request('queue/{}'.format(str(queue_id)))
+        msg = self.__request(f'queue/{queue_id}')
         lines = msg.split('\n')
         status_code = self.__get_status_code(lines[0])
         if status_code is not None and status_code == 200:
@@ -1206,7 +1201,7 @@ def get_queue(self, queue_id: typing.Union[str, int]) -> typing.Optional[typing.
                 return None
             return self.__parse_response_dict(lines)
 
-        raise UnexpectedMessageFormat('Received status code is {} instead of 200.'.format(status_code))
+        raise UnexpectedMessageFormatError(f'Received status code is {status_code} instead of 200.')
 
     def edit_queue(self, queue_id: typing.Union[str, int], **kwargs: typing.Any) -> typing.Union[str, bool]:
         """ Edit queue (undocumented API feature).
@@ -1223,8 +1218,8 @@ def edit_queue(self, queue_id: typing.Union[str, int], **kwargs: typing.Any) ->
                           * DefaultDueIn
 
         :returns: ID or name of edited queue or False when edit fails
-        :raises BadRequest: When queue does not exist
-        :raises InvalidUse: When invalid fields are set
+        :raises BadRequestError: When queue does not exist
+        :raises InvalidUseError: When invalid fields are set
         """
         valid_fields = {'name', 'description', 'correspondaddress', 'commentaddress', 'initialpriority',
                         'finalpriority',
@@ -1233,10 +1228,10 @@ def edit_queue(self, queue_id: typing.Union[str, int], **kwargs: typing.Any) ->
 
         if not used_fields <= valid_fields:
             invalid_fields = ", ".join(list(used_fields - valid_fields))
-            raise InvalidUse("Unsupported names of fields: {}.".format(invalid_fields))
-        post_data = 'id: queue/{}\n'.format(str(queue_id))
+            raise InvalidUseError(f"Unsupported names of fields: {invalid_fields}.")
+        post_data = f'id: queue/{queue_id}\n'
         for key, val in kwargs.items():
-            post_data += '{}: {}\n'.format(key, val)
+            post_data += f'{key}: {val}\n'
         msg = self.__request('edit', post_data={'content': post_data})
         msgs = msg.split('\n')
         if (self.__get_status_code(msg) == 200) and (len(msgs) > 2):
@@ -1251,8 +1246,8 @@ def create_queue(self, Name: str, **kwargs: typing.Any) -> int:
         :param Name: Queue name (required)
         :param kwargs: Optional fields to set (see edit_queue)
         :returns: ID of new queue or False when create fails
-        :raises BadRequest: When queue already exists
-        :raises InvalidUse: When invalid fields are set
+        :raises BadRequestError: When queue already exists
+        :raises InvalidUseError: When invalid fields are set
         """
         return int(self.edit_queue('new', Name=Name, **kwargs))
 
@@ -1272,9 +1267,9 @@ def get_links(self, ticket_id: typing.Union[str, int]) -> typing.Optional[typing
                       * DependedOnBy
 
                   None is returned if ticket does not exist.
-        :raises UnexpectedMessageFormat: In case that returned status code is not 200
+        :raises UnexpectedMessageFormatError: In case that returned status code is not 200
         """
-        msg = self.__request('ticket/{}/links/show'.format(str(ticket_id), ))
+        msg = self.__request(f'ticket/{ticket_id}/links/show')
         lines = msg.split('\n')
         status_code = self.__get_status_code(lines[0])
         if status_code is not None and status_code == 200:
@@ -1284,7 +1279,7 @@ def get_links(self, ticket_id: typing.Union[str, int]) -> typing.Optional[typing
             return {key: [link.rstrip(',') for link in links.split()]
                     for key, links in pairs.items()}
 
-        raise UnexpectedMessageFormat('Received status code is {} instead of 200.'.format(status_code))
+        raise UnexpectedMessageFormatError(f'Received status code is {status_code} instead of 200.')
 
     def edit_ticket_links(self, ticket_id: typing.Union[str, int], **kwargs: typing.Any) -> bool:
         """ Edit ticket links.
@@ -1308,8 +1303,8 @@ def edit_ticket_links(self, ticket_id: typing.Union[str, int], **kwargs: typing.
         """
         post_data = ''
         for key, value in kwargs.items():
-            post_data += "{}: {}\n".format(key, str(value))
-        msg = self.__request('ticket/{}/links'.format(str(ticket_id), ),
+            post_data += f"{key}: {value}\n"
+        msg = self.__request(f'ticket/{ticket_id}/links',
                              post_data={'content': post_data})
         state = msg.split('\n')[2]
         return self.RE_PATTERNS['links_updated_pattern'].match(state) is not None
@@ -1328,13 +1323,13 @@ def edit_link(self, ticket_id: typing.Union[str, int], link_name: str, link_valu
                   ``False``
                       Ticket with given ID does not exist or link to delete is
                       not found
-        :raises InvalidUse: When none or more then one links are specified. Also
+        :raises InvalidUseError: When none or more then one links are specified. Also
                             when wrong link name is used.
         """
         valid_link_names = {'dependson', 'dependedonby', 'refersto',
                             'referredtoby', 'hasmember', 'memberof'}
         if not link_name.lower() in valid_link_names:
-            raise InvalidUse("Unsupported name of link.")
+            raise InvalidUseError("Unsupported name of link.")
         post_data = {'rel': link_name.lower(),
                      'to': link_value,
                      'id': ticket_id,
@@ -1351,15 +1346,14 @@ def merge_ticket(self, ticket_id: typing.Union[str, int], into_id: typing.Union[
         """ Merge ticket into another (undocumented API feature).
 
         :param ticket_id: ID of ticket to be merged
-        :param into: ID of destination ticket
+        :param into_id: ID of destination ticket
         :returns: ``True``
                       Operation was successful
                   ``False``
                       Either origin or destination ticket does not
                       exist or user does not have ModifyTicket permission.
         """
-        msg = self.__request('ticket/{}/merge/{}'.format(str(ticket_id),
-                                                         str(into_id)))
+        msg = self.__request(f'ticket/{ticket_id}/merge/{into_id}')
         state = msg.split('\n')[2]
         return self.RE_PATTERNS['merge_successful_pattern'].match(state) is not None
 
@@ -1373,8 +1367,8 @@ def take(self, ticket_id: typing.Union[str, int]) -> bool:
                       Either the ticket does not exist or user does not
                       have TakeTicket permission.
         """
-        post_data = {'content': "Ticket: {}\nAction: take".format(str(ticket_id))}
-        msg = self.__request('ticket/{}/take'.format(str(ticket_id)), post_data=post_data)
+        post_data = {'content': f"Ticket: {ticket_id}\nAction: take"}
+        msg = self.__request(f'ticket/{ticket_id}/take', post_data=post_data)
         return self.__get_status_code(msg) == 200
 
     def steal(self, ticket_id: typing.Union[str, int]) -> bool:
@@ -1387,8 +1381,8 @@ def steal(self, ticket_id: typing.Union[str, int]) -> bool:
                       Either the ticket does not exist or user does not
                       have StealTicket permission.
         """
-        post_data = {'content': "Ticket: {}\nAction: steal".format(str(ticket_id))}
-        msg = self.__request('ticket/{}/take'.format(str(ticket_id)), post_data=post_data)
+        post_data = {'content': f"Ticket: {ticket_id}\nAction: steal"}
+        msg = self.__request(f'ticket/{ticket_id}/take', post_data=post_data)
         return self.__get_status_code(msg) == 200
 
     def untake(self, ticket_id: typing.Union[str, int]) -> bool:
@@ -1401,8 +1395,8 @@ def untake(self, ticket_id: typing.Union[str, int]) -> bool:
                       Either the ticket does not exist or user does not
                       own the ticket.
         """
-        post_data = {'content': "Ticket: {}\nAction: untake".format(str(ticket_id))}
-        msg = self.__request('ticket/{}/take'.format(str(ticket_id)), post_data=post_data)
+        post_data = {'content': f"Ticket: {ticket_id}\nAction: untake"}
+        msg = self.__request(f'ticket/{ticket_id}/take', post_data=post_data)
         return self.__get_status_code(msg) == 200
 
     @staticmethod
diff --git a/rt/rest2.py b/rt/rest2.py
new file mode 100644
index 0000000..f31bb06
--- /dev/null
+++ b/rt/rest2.py
@@ -0,0 +1,1439 @@
+"""Python interface to Request Tracker :term:`API`
+
+Description of Request Tracker :term:`REST` :term:`API`:
+https://docs.bestpractical.com/rt/5.0.2/RT/REST2.html
+"""
+
+import base64
+import dataclasses
+import datetime
+import json
+import logging
+import re
+import sys
+import typing
+from urllib.parse import urljoin
+
+import requests
+import requests.auth
+import requests_toolbelt
+
+import rt.exceptions
+from .exceptions import AuthorizationError, UnexpectedResponseError, NotFoundError, InvalidUseError
+
+if sys.version_info >= (3, 8):
+    from typing import Literal
+else:
+    from typing_extensions import Literal
+
+__license__ = """ Copyright (C) 2012 CZ.NIC, z.s.p.o.
+    Copyright (c) 2015 Genome Research Ltd.
+    Copyright (c) 2017 CERT Gouvernemental (GOVCERT.LU)
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+__docformat__ = "reStructuredText en"
+__authors__ = [
+    '"Jiri Machalek" <jiri.machalek@nic.cz>',
+    '"Joshua C. randall" <jcrandall@alum.mit.edu>',
+    '"Georges Toth" <georges.toth@govcert.etat.lu>',
+]
+
+VALID_TICKET_LINK_NAMES = ('Parent', 'Child', 'RefersTo',
+                           'ReferredToBy', 'DependsOn', 'DependedOnBy')
+TYPE_VALID_TICKET_LINK_NAMES = Literal['Parent', 'Child', 'RefersTo',
+                                       'ReferredToBy', 'DependsOn', 'DependedOnBy']
+TYPE_CONTENT_TYPE = Literal['text/plain', 'text/html']
+
+
+@dataclasses.dataclass
+class Attachment:
+    """Dataclass representing an attachment."""
+
+    file_name: str
+    file_type: str
+    file_content: bytes
+
+    def to_dict(self) -> typing.Dict[str, str]:
+        """Convert to a dictionary for submitting to the REST API."""
+        return {'FileName': self.file_name,
+                'FileType': self.file_type,
+                'FileContent': base64.b64encode(self.file_content).decode('utf-8')
+                }
+
+    def multipart_form_element(self) -> typing.Tuple[str, bytes, str]:
+        """Convert to a tuple as required for multipart-form-data submission."""
+        return (self.file_name,
+                self.file_content,
+                self.file_type,
+                )
+
+
+class Rt:
+    r""" :term:`API` for Request Tracker according to
+    https://docs.bestpractical.com/rt/5.0.2/RT/REST2.html. Interface is based on
+    :term:`REST` architecture, which is based on HTTP/1.1 protocol. This module
+    is therefore mainly sending and parsing special HTTP messages.
+
+    .. warning:: You need at least version 5.0.2 of RT.
+
+    .. note:: Use only ASCII LF as newline (``\\n``). Time is returned in UTC.
+              All strings returned are encoded in UTF-8 and the same is
+              expected as input for string values.
+    """
+
+    def __init__(self,
+                 url: str,
+                 proxy: typing.Optional[str] = None,
+                 verify_cert: typing.Optional[typing.Union[str, bool]] = True,
+                 http_auth: typing.Optional[requests.auth.AuthBase] = None,
+                 token: typing.Optional[str] = None,
+                 http_timeout: typing.Optional[int] = 20,
+                 ) -> None:
+        """ API initialization.
+
+        :param url: Base URL for Request Tracker API.
+                      E.g.: http://tracker.example.com/REST/2.0/
+        :param proxy: Proxy server (string with http://user:password@host/ syntax)
+        :param http_auth: Specify a http authentication instance, e.g. HTTPBasicAuth(), HTTPDigestAuth(),
+                            etc. to be used for authenticating to RT
+        :param token: Optional authentication token to be used instead of basic authentication.
+        :param http_timeout: HTTP timeout after which a request is aborted.
+
+        :raises ValueError: If the specified `url` is invalid.
+        """
+        self.logger = logging.getLogger(__name__)
+
+        # ensure trailing slash
+        if not url.endswith('/'):
+            url = f'{url}/'
+
+        if not url.endswith('REST/2.0/'):
+            raise ValueError('Invalid REST URL specified, please use a form of https://example.com/REST/2.0/')
+
+        self.url = url
+        self.base_url = url.split('REST/2.0/', 1)[0]
+
+        self.session = requests.session()
+        self.session.verify = verify_cert
+
+        if proxy is not None:  # pragma: no cover
+            if url.lower().startswith("https://"):
+                self.session.proxies = {"https": proxy}
+            else:
+                self.session.proxies = {"http": proxy}
+        if http_auth is not None:
+            self.session.auth = http_auth
+        if token is not None:  # pragma: no cover  # no way to add tests for this with the current docker image
+            self.session.headers['Authorization'] = f'token {token}'
+
+        self.http_timeout = http_timeout
+
+    def __debug_response(self, response: requests.Response) -> None:
+        """Output debug information for a given HTTP response."""
+        self.logger.debug("### %s", datetime.datetime.now().isoformat())
+        self.logger.debug("Request URL: %s", response.request.url)
+        self.logger.debug("Request method: %s", response.request.method)
+        self.logger.debug("Request headers: %s", response.request.headers)
+        self.logger.debug("Request body: %s", str(response.request.body))
+        self.logger.debug("Response status code: %s", str(response.status_code))
+        self.logger.debug("Response content:")
+        self.logger.debug(response.content.decode())
+
+    def __request(self,
+                  selector: str,
+                  get_params: typing.Optional[typing.Dict[str, typing.Any]] = None,
+                  json_data: typing.Optional[typing.Union[typing.Dict[str, typing.Any], typing.List[typing.Any]]] = None,
+                  post_data: typing.Optional[typing.Dict[str, typing.Any]] = None,
+                  attachments: typing.Optional[typing.Sequence[Attachment]] = None,
+                  ) -> typing.Union[typing.Dict[str, typing.Any], typing.List[str]]:
+        """ General request for :term:`API`.
+
+        :param selector: End part of URL which completes self.url parameter
+                           set during class initialization.
+                           E.g.: ``ticket/123456/show``
+        :param get_params: Parameters to add for a GET request.
+        :param json_data: JSON request to send to the API.
+        :param post_data: Dictionary with POST method fields
+        :param attachments: Optional list of :py:class:`~rt.rest2.Attachment` objects
+
+        :returns: dict or list depending on request
+        :raises AuthorizationError: In case that request is called without previous
+                                    login or login attempt failed.
+        :raises ConnectionError: In case of connection error.
+        """
+        try:
+            url = str(urljoin(self.url, selector))
+            if not attachments:
+                if json_data:
+                    response = self.session.post(url, json=json_data, timeout=self.http_timeout)
+                elif post_data:
+                    response = self.session.post(url, data=post_data, timeout=self.http_timeout)
+                else:
+                    response = self.session.get(url, params=get_params, timeout=self.http_timeout)
+            else:
+                fields: typing.List[typing.Tuple[str, typing.Any]] = [('Attachments', attachment.multipart_form_element()) for attachment in attachments]
+                fields.append(('JSON', json.dumps(json_data)))
+
+                multipart_data = requests_toolbelt.MultipartEncoder(fields=fields)
+
+                _headers = dict(self.session.headers)
+                _headers['content-type'] = multipart_data.content_type
+
+                response = self.session.post(url, data=multipart_data, headers=_headers, timeout=self.http_timeout)
+
+            self.__debug_response(response)
+            self.__check_response(response)
+
+            try:
+                result = response.json()
+            except LookupError as exc:  # pragma: no cover
+                raise UnexpectedResponseError(f'Unknown response encoding: {response.encoding}.') from exc
+            except UnicodeError as exc:  # pragma: no cover
+                raise UnexpectedResponseError(f'''Unknown response encoding (UTF-8 does not work) - "{response.content.decode('utf-8', 'replace')}".''') from exc
+
+            return result
+        except requests.exceptions.ConnectionError as exc:  # pragma: no cover
+            raise ConnectionError("Connection error", exc) from exc
+
+    def __request_put(self,
+                      selector: str,
+                      json_data: typing.Optional[typing.Dict[str, typing.Any]] = None
+                      ) -> typing.List[str]:
+        """ PUT request for :term:`API`.
+
+        :param selector: End part of URL which completes self.url parameter
+                           set during class initialization.
+                           E.g.: ``ticket/123456/show``
+        :param json_data: JSON request to send to the API.
+        :returns: list
+        :raises AuthorizationError: In case that request is called without previous
+                                    login or login attempt failed.
+        :raises ConnectionError: In case of connection error.
+        """
+        try:
+            url = str(urljoin(self.url, selector))
+
+            _headers = dict(self.session.headers)
+            _headers['Content-Type'] = 'application/json'
+            response = self.session.put(url, json=json_data, headers=_headers, timeout=self.http_timeout)
+
+            self.__debug_response(response)
+            self.__check_response(response)
+
+            try:
+                result = response.json()
+            except LookupError as exc:  # pragma: no cover
+                raise UnexpectedResponseError(f'Unknown response encoding: {response.encoding}.') from exc
+            except UnicodeError as exc:  # pragma: no cover
+                raise UnexpectedResponseError(f'''Unknown response encoding (UTF-8 does not work) - "{response.content.decode('utf-8', 'replace')}".''') from exc
+
+            return result
+        except requests.exceptions.ConnectionError as exc:  # pragma: no cover
+            raise ConnectionError("Connection error", exc) from exc
+
+    def __request_delete(self,
+                         selector: str,
+                         ) -> typing.Dict[str, str]:
+        """ DELETE request for :term:`API`.
+
+        :param selector: End part of URL which completes self.url parameter
+                           set during class initialization.
+                           E.g.: ``ticket/123456/show``
+
+        :returns: dict
+        :raises AuthorizationError: In case that request is called without previous
+                                    login or login attempt failed.
+        :raises ConnectionError: In case of connection error.
+        """
+        try:
+            url = str(urljoin(self.url, selector))
+
+            _headers = dict(self.session.headers)
+            _headers['Content-Type'] = 'application/json'
+            response = self.session.delete(url, headers=_headers, timeout=self.http_timeout)
+
+            self.__debug_response(response)
+            self.__check_response(response)
+
+            try:
+                result = response.json()
+            except LookupError as exc:  # pragma: no cover
+                raise UnexpectedResponseError(f'Unknown response encoding: {response.encoding}.') from exc
+            except UnicodeError as exc:  # pragma: no cover
+                raise UnexpectedResponseError(f'''Unknown response encoding (UTF-8 does not work) - "{response.content.decode('utf-8', 'replace')}".''') from exc
+
+            return result
+        except requests.exceptions.ConnectionError as exc:  # pragma: no cover
+            raise ConnectionError("Connection error", exc) from exc
+
+    def __paged_request(self,
+                        selector: str,
+                        json_data: typing.Optional[typing.Union[typing.List[typing.Dict[str, typing.Any]], typing.Dict[str, typing.Any]]] = None,
+                        params: typing.Optional[typing.Dict[str, typing.Any]] = None,
+                        page: int = 1,
+                        per_page: int = 20,
+                        recurse: bool = True
+                        ) -> typing.Iterator[typing.Dict[str, typing.Any]]:
+        """ Request using pagination for :term:`API`.
+
+        :param selector: End part of URL which completes self.url parameter
+                           set during class initialization.
+                           E.g.: ``ticket/123456/show``
+        :param json_data: JSON request to send to the API.
+        :param page: The page number to get.
+        :param per_page: Number of results per page to get.
+        :param recurse: Set on the initial call in order to retrieve all pages recursively.
+
+        :returns: dict
+        :raises AuthorizationError: In case that request is called without previous
+                                    login or login attempt failed.
+        :raises ConnectionError: In case of connection error.
+        """
+        if params:
+            params['page'] = page
+            params['per_page'] = per_page
+        else:
+            params = {'page': page, 'per_page': per_page}
+
+        try:
+            url = str(urljoin(self.url, selector))
+
+            method = 'get'
+            if json_data is not None:
+                method = 'post'
+
+            response = self.session.request(method, url, json=json_data, params=params, timeout=self.http_timeout)
+
+            self.__check_response(response)
+
+            try:
+                result = response.json()
+            except LookupError as exc:  # pragma: no cover
+                raise UnexpectedResponseError(f'Unknown response encoding: {response.encoding}.') from exc
+            except UnicodeError:  # pragma: no cover
+                # replace errors - we need decoded content just to check for error codes in __check_response
+                result = response.content.decode('utf-8', 'replace')
+
+            if not isinstance(result, dict) and 'items' in result:
+                raise UnexpectedResponseError('Server returned an unexpected result')
+
+            yield from result['items']
+
+            if recurse and result['pages'] > result['page']:
+                for _page in range(2, result['pages'] + 1):
+                    yield from self.__paged_request(selector, json_data=json_data, page=_page,
+                                                    per_page=result['per_page'], params=params, recurse=False)
+
+        except requests.exceptions.ConnectionError as exc:  # pragma: no cover
+            raise ConnectionError("Connection error", exc) from exc
+
+    @staticmethod
+    def __check_response(response: requests.Response) -> None:
+        """ Search general errors in server response and raise exceptions when found.
+
+        :param response: Response from HTTP request.
+        :raises BadRequestError: If the server returned an HTTP/400 error.
+        :raises AuthorizationError: Credentials are invalid or missing.
+        :raises NotFoundError: Resource was not found.
+        :raises UnexpectedResponseError: Server returned an unexpected status code.
+        """
+        if response.status_code == 400:  # pragma: no cover
+            try:
+                ret = response.json()
+            except json.JSONDecodeError:
+                ret = 'Bad request'
+
+            if isinstance(ret, dict):
+                raise rt.exceptions.BadRequestError(ret['message'])
+
+            raise rt.exceptions.BadRequestError(ret)
+
+        if response.status_code == 401:  # pragma: no cover
+            raise AuthorizationError(
+                'Server could not verify that you are authorized to access the requested document.')
+        if response.status_code == 404:
+            raise NotFoundError('No such resource found.')
+        if response.status_code not in (200, 201):
+            raise UnexpectedResponseError(f'Received status code {response.status_code} instead of 200.',
+                                          status_code=response.status_code,
+                                          response_message=response.text)
+
+    def __get_url(self, url: str) -> typing.Dict[str, typing.Any]:
+        """Call a URL as specified in the returned JSON of an API operation."""
+        url_ = url.split('/REST/2.0/', 1)[1]
+        res = self.__request(url_)
+
+        if not isinstance(res, dict):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return res
+
+    def new_correspondence(self, queue: typing.Optional[typing.Union[str, object]] = None) -> typing.Iterator[dict]:
+        """ Obtains tickets changed by other users than the system one.
+
+        :param queue: Queue where to search
+
+        :returns: Iterator of tickets which were last updated by another user than
+                  the system one, ordered in decreasing order by LastUpdated.
+                  Each ticket is dictionary, the same as in
+                  :py:meth:`~Rt.get_ticket`.
+        """
+        return self.search(queue=queue, order='-LastUpdated')
+
+    def last_updated(self, since: str, queue: typing.Optional[str] = None) -> typing.Iterator[dict]:
+        """ Obtains tickets changed after given date.
+
+        :param since: Date as string in form '2011-02-24'
+        :param queue: Queue where to search
+
+        :returns: Iterator of tickets with LastUpdated parameter later than
+                  *since* ordered in decreasing order by LastUpdated.
+                  Each ticket is a dictionary, the same as in
+                  :py:meth:`~Rt.get_ticket`.
+
+        :raises InvalidUseError: If the specified date is of an unsupported format.
+        """
+        if not self.__validate_date(since):
+            raise InvalidUseError(f'Invalid date specified - "{since}"')
+
+        return self.search(queue=queue, order='-LastUpdated',
+                           LastUpdated__gt=since)
+
+    @classmethod
+    def __validate_date(cls, _date: str) -> bool:
+        """Check whether the specified date is in the supported format."""
+        m = re.match(r'(\d{4})-(\d{2})-(\d{2})', _date)
+        if m:
+            try:
+                year = int(m.group(1))
+                month = int(m.group(2))
+                day = int(m.group(3))
+            except ValueError:
+                return False
+
+            if 1970 < year < 2100 and 1 < month <= 12 and 1 < day <= 31:
+                return True
+
+        return False
+
+    def search(self, queue: typing.Optional[typing.Union[str, object]] = None, order: typing.Optional[str] = None,
+               raw_query: typing.Optional[str] = None, query_format: str = 'l', **kwargs: typing.Any) -> typing.Iterator[dict]:
+        r""" Search arbitrary needles in given fields and queue.
+
+        Example::
+
+            >>> tracker = Rt('http://tracker.example.com/REST/2.0/', 'rt-username', 'top-secret')
+            >>> tickets = list(tracker.search(CF_Domain='example.com', Subject__like='warning'))
+            >>> tickets = list(tracker.search(queue='General', order='Status', raw_query="id='1'+OR+id='2'+OR+id='3'"))
+
+        :param queue:      Queue where to search. If you wish to search across
+                           all of your queues, pass the ALL_QUEUES object as the
+                           argument.
+        :param order:      Name of field sorting result list, for descending
+                           order put - before the field name. E.g. -Created
+                           will put the newest tickets at the beginning
+        :param raw_query:  A raw query to provide to RT if you know what
+                             you are doing. You may still pass Queue and order
+                             kwargs, so use these instead of including them in
+                             the raw query. You can refer to the RT query builder.
+                             If passing raw_query, all other \*\*kwargs will be ignored.
+        :param query_format: Format of the query:
+
+                               - i: only *id* fields are populated
+                               - s: only *id* and *subject* fields are populated
+                               - l: multi-line format, all fields are populated
+        :param kwargs:     Other arguments possible to set if not passing raw_query:
+
+                             Requestors, Subject, Cc, AdminCc, Owner, Status,
+                             Priority, InitialPriority, FinalPriority,
+                             TimeEstimated, Starts, Due, Text,... (according to RT
+                             fields)
+
+                             Custom fields CF.{<CustomFieldName>} could be set
+                             with keywords CF_CustomFieldName.
+
+                             To alter lookup operators you can append one of the
+                             following endings to each keyword:
+
+                             __exact    for operator = (default)
+                             __notexact for operator !=
+                             __gt       for operator >
+                             __lt       for operator <
+                             __like     for operator LIKE
+                             __notlike  for operator NOT LIKE
+
+                             Setting values to keywords constrain search
+                             result to the tickets satisfying all of them.
+
+        :returns: Iterator over matching tickets. Each ticket is the same dictionary
+                  as in :py:meth:`~Rt.get_ticket`.
+        :raises:  UnexpectedMessageFormatError: Unexpected format of returned message.
+                  InvalidQueryError: If raw query is malformed
+        """
+        get_params = {}
+        query = []
+        url = 'tickets'
+
+        if queue is not None:
+            query.append(f'Queue=\'{queue}\'')
+        if not raw_query:
+            operators_map = {
+                'gt': '>',
+                'lt': '<',
+                'exact': '=',
+                'notexact': '!=',
+                'like': ' LIKE ',
+                'notlike': ' NOT LIKE '
+            }
+
+            for key, value in kwargs.items():
+                op = '='
+                key_parts = key.split('__')
+                if len(key_parts) > 1:
+                    key = '__'.join(key_parts[:-1])
+                    op = operators_map.get(key_parts[-1], '=')
+                if key[:3] != 'CF_':
+                    query.append(f'{key}{op}\'{value}\'')
+                else:
+                    query.append(f'''CF.{{{key[3:]}}}'{op}'{value}\'''')
+        else:
+            query.append(raw_query)
+        get_params['query'] = ' AND '.join('(' + part + ')' for part in query)
+        if order:
+            get_params['orderby'] = order
+
+        if query_format == 'l':
+            get_params['fields'] = 'Owner,Status,Created,Subject,Queue,CustomFields,Requestor,Cc,AdminCc,Started,Created,TimeEstimated,Due,Type,InitialPriority,Priority,TimeLeft,LastUpdated'
+            get_params['fields[Queue]'] = 'Name'
+        elif query_format == 's':
+            get_params['fields'] = 'Subject'
+
+        yield from self.__paged_request(url, params=get_params)
+
+    def get_ticket(self, ticket_id: typing.Union[str, int]) -> dict:
+        """ Fetch ticket by its ID.
+
+        :param ticket_id: ID of demanded ticket
+
+        :returns: Dictionary with key, value pairs for ticket with
+                  *ticket_id* or None if ticket does not exist. List of keys:
+
+                      * id
+                      * numerical_id
+                      * Queue
+                      * Owner
+                      * Creator
+                      * Subject
+                      * Status
+                      * Priority
+                      * InitialPriority
+                      * FinalPriority
+                      * Requestors
+                      * Cc
+                      * AdminCc
+                      * Created
+                      * Starts
+                      * Started
+                      * Due
+                      * Resolved
+                      * Told
+                      * TimeEstimated
+                      * TimeWorked
+                      * TimeLeft
+        :raises UnexpectedMessageFormatError: Unexpected format of returned message.
+        :raises NotFoundError: If there is no ticket with the specified ticket_id.
+        """
+        res = self.__request(f'ticket/{ticket_id}', get_params={'fields[Queue]': 'Name'})
+
+        if not isinstance(res, dict):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return res
+
+    def create_ticket(self,
+                      queue: str,
+                      content_type: TYPE_CONTENT_TYPE = 'text/plain',
+                      subject: typing.Optional[str] = None,
+                      content: typing.Optional[str] = None,
+                      attachments: typing.Optional[typing.Sequence[Attachment]] = None,
+                      **kwargs: typing.Any) -> int:
+        """ Create new ticket and set given parameters.
+
+        Example of message sent to ``http://tracker.example.com/REST/2.0/ticket/new``::
+
+            { "Queue": "General",
+              "Subject": "Ticket created through REST API",
+              "Owner": "Nobody",
+              "Requestor": "somebody@example.com",
+              "Cc": "user2@example.com",
+              "CustomRoles": {"My Role": "staff1@example.com"},
+              "Content": "Lorem Ipsum",
+              "CustomFields": {"Severity": "Low"}
+            }
+
+        :param queue: Queue where to create ticket
+        :param content_type: Content-type of the Content parameter; can be either text/plain or text/html.
+        :param subject: Optional subject for the ticket.
+        :param content: Optional content of the ticket. Must be specified unless attachments are specified.
+        :param attachments: Optional list of :py:class:`~rt.rest2.Attachment` objects
+        :param kwargs: Other arguments possible to set:
+
+                         Requestors, Cc, AdminCc, Owner, Status,
+                         Priority, InitialPriority, FinalPriority,
+                         TimeEstimated, Starts, Due
+
+        :returns: ID of new ticket
+        :raises ValueError: If the `content_type` is not of a supported format.
+        """
+        if content_type not in ('text/plain', 'text/html'):  # pragma: no cover
+            raise ValueError('Invalid content-type specified.')
+
+        ticket_data: typing.Dict[str, typing.Any] = {'Queue': queue}
+
+        if subject is not None:
+            ticket_data['Subject'] = subject
+
+        if content is not None:
+            ticket_data['Content'] = content
+            ticket_data['ContentType'] = content_type
+
+        for k, v in kwargs.items():
+            ticket_data[k] = v
+
+        res = self.__request('ticket', json_data=ticket_data, attachments=attachments)
+
+        if not isinstance(res, dict):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return int(res['id'])
+
+    def edit_ticket(self, ticket_id: typing.Union[str, int], **kwargs: typing.Any) -> bool:
+        """ Edit ticket values.
+
+        :param ticket_id: ID of ticket to edit
+        :param kwargs: Other arguments possible to set:
+
+                         Requestors, Subject, Cc, AdminCc, Owner, Status,
+                         Priority, InitialPriority, FinalPriority,
+                         TimeEstimated, Starts, Due, Text,... (according to RT
+                         fields)
+
+                         Custom fields can be specified as dict:
+                            CustomFields = {"Severity": "Low"}
+
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Ticket with given ID does not exist or unknown parameter
+                      was set (in this case all other valid fields are changed)
+        """
+        msg = self.__request_put(f'ticket/{ticket_id}', json_data=kwargs)
+
+        self.logger.debug(msg)
+
+        if not isinstance(msg, list):  # pragma: no cover
+            raise UnexpectedResponseError(str(msg))
+
+        if not msg:
+            return True
+
+        return bool(msg[0])
+
+    def get_ticket_history(self, ticket_id: typing.Union[str, int]) -> typing.Optional[typing.List[typing.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.
+        """
+        transactions = self.__paged_request(f'ticket/{ticket_id}/history', params={'fields': 'Type,Creator,Created,Description',
+                                                                                   'fields[Creator]': 'id,Name,RealName,EmailAddress'
+                                                                                   }
+                                            )
+
+        return list(transactions)
+
+    def get_transaction(self, transaction_id: typing.Union[str, int]) -> typing.Dict[str, typing.Any]:
+        """Get a transaction
+
+        :param transaction_id: ID of transaction
+        :returns: Return a single transaction.
+        """
+        res = self.__request(f'transaction/{transaction_id}', get_params={'fields': 'Description'})
+
+        if not isinstance(res, dict):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return res
+
+    def __correspond(self,
+                     ticket_id: typing.Union[str, int],
+                     content: str = '',
+                     action: Literal['correspond', 'comment'] = 'correspond',
+                     content_type: TYPE_CONTENT_TYPE = 'text/plain',
+                     attachments: typing.Optional[typing.Sequence[Attachment]] = None,
+                     ) -> typing.List[str]:
+        """ Sends out the correspondence
+
+        :param ticket_id: ID of ticket to which message belongs
+        :param content: Content of email message
+        :param action: correspond or comment
+        :param content_type: Content type of email message, defaults to text/plain. Alternative is text/html.
+        :param attachments: Optional list of :py:class:`~rt.rest2.Attachment` objects
+        :returns: List of messages returned by the backend related to the executed action.
+        :raises BadRequestError: When ticket does not exist
+        :raises InvalidUseError: If the `action` parameter is invalid
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        if action not in ('correspond', 'comment'):  # pragma: no cover
+            raise InvalidUseError('action must be either "correspond" or "comment"')
+
+        post_data: typing.Dict[str, typing.Any] = {'Content': content,
+                                                   'ContentType': content_type,
+                                                   }
+
+        # Adding a one-shot cc/bcc is not supported by RT5.0.2
+        # if cc:
+        #     post_data['Cc'] = cc
+        #
+        # if bcc:
+        #     post_data['Bcc'] = bcc
+
+        res = self.__request(f'ticket/{ticket_id}/{action}', json_data=post_data, attachments=attachments)
+
+        if not isinstance(res, list):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        self.logger.debug(res)
+
+        return res
+
+    def reply(self,
+              ticket_id: typing.Union[str, int],
+              content: str = '',
+              content_type: TYPE_CONTENT_TYPE = 'text/plain',
+              attachments: typing.Optional[typing.Sequence[Attachment]] = None,
+              ) -> bool:
+        """ Sends email message to the contacts in ``Requestors`` field of
+        given ticket with subject as is set in ``Subject`` field.
+
+        :param ticket_id: ID of ticket to which message belongs
+        :param content: Content of email message (text/plain or text/html)
+        :param content_type: Content type of email message, default to text/plain
+        :param attachments: Optional list of :py:class:`~rt.rest2.Attachment` objects
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Sending failed (status code != 200)
+        :raises BadRequestError: When ticket does not exist
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        msg = self.__correspond(ticket_id, content, 'correspond', content_type, attachments)
+
+        if not (isinstance(msg, list) and len(msg) >= 1):  # pragma: no cover
+            raise UnexpectedResponseError(str(msg))
+
+        return bool(msg[0])
+
+    def comment(self,
+                ticket_id: typing.Union[str, int],
+                content: str = '',
+                content_type: TYPE_CONTENT_TYPE = 'text/plain',
+                attachments: typing.Optional[typing.Sequence[Attachment]] = None,
+                ) -> bool:
+        """ Adds comment to the given ticket.
+
+        :param ticket_id: ID of ticket to which comment belongs
+        :param content: Content of comment
+        :param content_type: Content type of comment, default to text/plain
+        :param attachments: Optional list of :py:class:`~rt.rest2.Attachment` objects
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Sending failed (status code != 200)
+        :raises BadRequestError: When ticket does not exist
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        msg = self.__correspond(ticket_id, content, 'comment', content_type, attachments)
+
+        if not (isinstance(msg, list) and len(msg) >= 1):  # pragma: no cover
+            raise UnexpectedResponseError(str(msg))
+
+        return bool(msg[0])
+
+    def get_attachments(self, ticket_id: typing.Union[str, int]) -> typing.Sequence[typing.Dict[str, str]]:
+        """ Get attachment list for a given ticket
+
+        Example of a return result:
+
+        .. code-block:: json
+
+            [
+                {
+                    "id": "17",
+                    "Filename": "README.rst",
+                    "ContentLength": "3578",
+                    "type": "attachment",
+                    "ContentType": "test/plain",
+                    "_url": "http://localhost:8080/REST/2.0/attachment/17"
+                }
+            ]
+
+        :param ticket_id: ID of ticket
+        :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 = []
+
+        for item in self.__paged_request(f'ticket/{ticket_id}/attachments',
+                                         json_data=[{"field": "Filename", "operator": "IS NOT", "value": ""}],
+                                         params={'fields': 'Filename,ContentType,ContentLength'}):
+            attachments.append(item)
+
+        return attachments
+
+    def get_attachments_ids(self, ticket_id: typing.Union[str, int]) -> typing.Optional[typing.List[int]]:
+        """ Get IDs of attachments for given ticket.
+
+        :param ticket_id: ID of ticket
+        :returns: List of IDs (type int) of attachments belonging to given
+                  ticket. Returns None if ticket does not exist.
+        """
+        attachments = []
+
+        for item in self.__paged_request(f'ticket/{ticket_id}/attachments',
+                                         json_data=[{"field": "Filename", "operator": "IS NOT", "value": ""}]
+                                         ):
+            attachments.append(int(item['id']))
+
+        return attachments
+
+    def get_attachment(self, attachment_id: typing.Union[str, int]) -> typing.Optional[dict]:
+        """ Get attachment.
+
+        :param attachment_id: ID of attachment to fetch
+        :returns: Attachment as dictionary with these keys:
+
+                      * Transaction
+                      * ContentType
+                      * Parent
+                      * Creator
+                      * Created
+                      * Filename
+                      * Content (base64 encoded string)
+                      * Headers
+                      * MessageId
+                      * ContentEncoding
+                      * id
+                      * Subject
+
+                  All these fields are strings, just 'Headers' holds another
+                  dictionary with attachment headers as strings e.g.:
+
+                      * Delivered-To
+                      * From
+                      * Return-Path
+                      * Content-Length
+                      * To
+                      * X-Seznam-User
+                      * X-QM-Mark
+                      * Domainkey-Signature
+                      * RT-Message-ID
+                      * X-RT-Incoming-Encryption
+                      * X-Original-To
+                      * Message-ID
+                      * X-Spam-Status
+                      * In-Reply-To
+                      * Date
+                      * Received
+                      * X-Country
+                      * X-Spam-Checker-Version
+                      * X-Abuse
+                      * MIME-Version
+                      * Content-Type
+                      * Subject
+
+                  Set of headers available depends on mailservers sending
+                  emails not on Request Tracker!
+
+        :raises UnexpectedMessageFormatError: Unexpected format of returned message.
+        :raises NotFoundError: If attachment with specified ID does not exist.
+        """
+        res = self.__request(f'attachment/{attachment_id}')
+
+        if not (res is None or isinstance(res, dict)):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return res
+
+    def get_user(self, user_id: typing.Union[int, str]) -> typing.Dict[str, typing.Any]:
+        """ Get user details.
+
+        :param user_id: Identification of user by username (str) or user ID
+                        (int)
+        :returns: User details as strings in dictionary with these keys for RT
+                  users:
+
+                      * Lang
+                      * RealName
+                      * Privileged
+                      * Disabled
+                      * Gecos
+                      * EmailAddress
+                      * Password
+                      * id
+                      * Name
+
+                  Or these keys for external users (e.g. Requestors) replying
+                  to email from RT:
+
+                      * RealName
+                      * Disabled
+                      * EmailAddress
+                      * Password
+                      * id
+                      * Name
+
+        :raises UnexpectedMessageFormatError: In case that returned status code is not 200
+        :raises NotFoundError: If the user does not exist.
+        """
+        res = self.__request(f'user/{user_id}')
+
+        if not isinstance(res, dict):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return res
+
+    def user_exists(self, user_id: typing.Union[int, str], privileged: bool = True) -> bool:
+        """Check if a given user_id exists.
+
+        :parameter user_id: User ID to lookup.
+        :parameter privileged: If set to True, only return True if the user_id was found and is privileged.
+
+        :returns: bool: True on success, else False.
+        """
+        try:
+            user_dict = self.get_user(user_id)
+
+            if not privileged or (privileged and user_dict.get('Privileged', '0') == 1):
+                return True
+        except rt.exceptions.NotFoundError:
+            return False
+
+        return False
+
+    def create_user(self, user_name: str, email_address: str, **kwargs: typing.Any) -> str:
+        """ Create user.
+
+        :param user_name: Username (login for privileged, required)
+        :param email_address: Email address (required)
+        :param kwargs: Optional fields to set (see edit_user)
+        :returns: ID of new user or False when create fails
+        :raises BadRequestError: When user already exists
+        :raises InvalidUseError: When invalid fields are set
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        valid_fields = {'Name', 'Password', 'EmailAddress', 'RealName',
+                        'Nickname', 'Gecos', 'Organization', 'Address1', 'Address2',
+                        'City', 'State', 'Zip', 'Country', 'HomePhone', 'WorkPhone',
+                        'MobilePhone', 'PagerPhone', 'ContactInfo', 'Comments',
+                        'Signature', 'Lang', 'EmailEncoding', 'WebEncoding',
+                        'ExternalContactInfoId', 'ContactInfoSystem', 'ExternalAuthId',
+                        'AuthSystem', 'Privileged', 'Disabled'}
+        invalid_fields = []
+
+        post_data = {'Name': user_name,
+                     'EmailAddress': email_address
+                     }
+
+        for k, v in kwargs.items():
+            if k not in valid_fields:
+                invalid_fields.append(k)
+
+            else:
+                post_data[k] = v
+
+        if invalid_fields:
+            raise InvalidUseError(f'''Unsupported names of fields: {', '.join(invalid_fields)}.''')
+
+        try:
+            res = self.__request('user', json_data=post_data)
+        except UnexpectedResponseError as exc:  # pragma: no cover
+            if exc.status_code == 400:
+                raise rt.exceptions.BadRequestError(exc.response_message) from exc
+
+            raise
+
+        if not isinstance(res, dict):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return res['id']
+
+    def edit_user(self, user_id: typing.Union[str, int], **kwargs: typing.Any) -> typing.List[str]:
+        """ Edit user profile.
+
+        :param user_id: Identification of user by username (str) or user ID
+                        (int)
+        :param kwargs: Other fields to edit from the following list:
+
+                          * Name
+                          * Password
+                          * EmailAddress
+                          * RealName
+                          * NickName
+                          * Gecos
+                          * Organization
+                          * Address1
+                          * Address2
+                          * City
+                          * State
+                          * Zip
+                          * Country
+                          * HomePhone
+                          * WorkPhone
+                          * MobilePhone
+                          * PagerPhone
+                          * ContactInfo
+                          * Comments
+                          * Signature
+                          * Lang
+                          * EmailEncoding
+                          * WebEncoding
+                          * ExternalContactInfoId
+                          * ContactInfoSystem
+                          * ExternalAuthId
+                          * AuthSystem
+                          * Privileged
+                          * Disabled
+
+        :returns: List of status messages
+        :raises BadRequestError: When user does not exist
+        :raises InvalidUseError: When invalid fields are set
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        valid_fields = {'Name', 'Password', 'EmailAddress', 'RealName',
+                        'Nickname', 'Gecos', 'Organization', 'Address1', 'Address2',
+                        'City', 'State', 'Zip', 'Country', 'HomePhone', 'WorkPhone',
+                        'MobilePhone', 'PagerPhone', 'ContactInfo', 'Comments',
+                        'Signature', 'Lang', 'EmailEncoding', 'WebEncoding',
+                        'ExternalContactInfoId', 'ContactInfoSystem', 'ExternalAuthId',
+                        'AuthSystem', 'Privileged', 'Disabled'}
+        invalid_fields = []
+
+        post_data = {}
+
+        for key, val in kwargs.items():
+            if key not in valid_fields:
+                invalid_fields.append(key)
+
+            else:
+                post_data[key] = val
+
+        if invalid_fields:
+            raise InvalidUseError(f'''Unsupported names of fields: {', '.join(invalid_fields)}.''')
+
+        try:
+            ret = self.__request_put(f'user/{user_id}', json_data=post_data)
+        except UnexpectedResponseError as exc:  # pragma: no cover
+            if exc.status_code == 400:
+                raise rt.exceptions.BadRequestError(exc.response_message) from exc
+
+            raise
+
+        return ret
+
+    def delete_user(self, user_id: typing.Union[str, int]) -> None:
+        """ Disable a user.
+
+        :param user_id: Identification of a user by name (str) or ID (int)
+
+        :raises BadRequestError: When user does not exist
+        :raises NotFoundError: If the user does not exist
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        try:
+            _ = self.__request_delete(f'user/{user_id}')
+        except UnexpectedResponseError as exc:
+            if exc.status_code == 400:  # pragma: no cover
+                raise rt.exceptions.BadRequestError(exc.response_message) from exc
+
+            if exc.status_code == 204:
+                return
+
+            raise  # pragma: no cover
+
+    def get_queue(self, queue_id: typing.Union[str, int]) -> typing.Optional[typing.Dict[str, typing.Any]]:
+        """ Get queue details.
+
+        Example of a return result:
+
+        .. code-block:: json
+
+            {
+                "LastUpdatedBy": {
+                    "_url": "http://localhost:8080/REST/2.0/user/RT_System",
+                    "type": "user",
+                    "id": "RT_System"
+                },
+                "LastUpdated": "2022-03-06T04:53:38Z",
+                "AdminCc": [],
+                "SortOrder": "0",
+                "CorrespondAddress": "",
+                "Creator": {
+                    "id": "RT_System",
+                    "_url": "http://localhost:8080/REST/2.0/user/RT_System",
+                    "type": "user"
+                },
+                "Lifecycle": "default",
+                "Cc": [],
+                "Created": "2022-03-06T04:53:38Z",
+                "_hyperlinks": [
+                    {
+                        "_url": "http://localhost:8080/REST/2.0/queue/1",
+                        "type": "queue",
+                        "id": 1,
+                        "ref": "self"
+                    },
+                    {
+                        "ref": "history",
+                        "_url": "http://localhost:8080/REST/2.0/queue/1/history"
+                    },
+                    {
+                        "ref": "create",
+                        "type": "ticket",
+                        "_url": "http://localhost:8080/REST/2.0/ticket?Queue=1"
+                    }
+                ],
+                "SLADisabled": "1",
+                "Name": "General",
+                "TicketCustomFields": [],
+                "Disabled": "0",
+                "TicketTransactionCustomFields": [],
+                "CustomFields": [],
+                "Description": "The default queue",
+                "CommentAddress": "",
+                "id": 1
+            }
+
+        :param queue_id: Identification of queue by name (str) or queue ID
+                         (int)
+        :returns: Queue details as a dictionary
+
+        :raises UnexpectedMessageFormatError: In case that returned status code is not 200
+        :raises NotFoundError: In case the queue does not exist
+        """
+        res = self.__request(f'queue/{queue_id}')
+
+        if not (res is None or isinstance(res, dict)):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return res
+
+    def get_all_queues(self, include_disabled: bool = False) -> typing.List[typing.Dict[str, typing.Any]]:
+        """ Return a list of all queues.
+
+        Example of a return result:
+
+        .. code-block:: json
+
+            [
+                {
+                    "InitialPriority": "",
+                    "_url": "http://localhost:8080/REST/2.0/queue/1",
+                    "type": "queue",
+                    "Name": "General",
+                    "DefaultDueIn": "",
+                    "Description": "The default queue",
+                    "CorrespondAddress": "",
+                    "CommentAddress": "",
+                    "id": "1",
+                    "FinalPriority": ""
+                }
+            ]
+
+        :param include_disabled: Set to True to also return disabled queues.
+
+        :returns: Returns a list of dictionaries containing basic information on all queues.
+
+        :raises UnexpectedMessageFormatError: In case that returned status code is not 200
+        """
+        params = {'fields': 'Name,Description,CorrespondAddress,CommentAddress,InitialPriority,FinalPriority,DefaultDueIn',
+                  'find_disabled_rows': int(include_disabled)
+                  }
+        queues = self.__paged_request('queues/all', params=params)
+
+        return list(queues)
+
+    def edit_queue(self, queue_id: typing.Union[str, int], **kwargs: typing.Any) -> typing.List[str]:
+        """ Edit queue.
+
+        :param queue_id: Identification of queue by name (str) or ID (int)
+        :param kwargs: Other fields to edit from the following list:
+
+                          * Name
+                          * Description
+                          * CorrespondAddress
+                          * CommentAddress
+                          * Disabled
+                          * SLADisabled
+                          * Lifecycle
+                          * SortOrder
+
+        :returns: List of status messages
+        :raises BadRequestError: When queue does not exist
+        :raises InvalidUseError: When invalid fields are set
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        valid_fields = {'Name', 'Description', 'CorrespondAddress', 'CommentAddress',
+                        'Disabled', 'SLADisabled', 'Lifecycle', 'SortOrder'
+                        }
+        invalid_fields = []
+
+        post_data = {}
+
+        for key, val in kwargs.items():
+            if key not in valid_fields:
+                invalid_fields.append(key)
+
+            else:
+                post_data[key] = val
+
+        if invalid_fields:
+            raise InvalidUseError(f'''Unsupported names of fields: {', '.join(invalid_fields)}.''')
+
+        try:
+            ret = self.__request_put(f'queue/{queue_id}', json_data=post_data)
+        except UnexpectedResponseError as exc:  # pragma: no cover
+            if exc.status_code == 400:
+                raise rt.exceptions.BadRequestError(exc.response_message) from exc
+
+            raise
+
+        return ret
+
+    def create_queue(self, name: str, **kwargs: typing.Any) -> int:
+        """ Create queue.
+
+        :param name: Queue name (required)
+        :param kwargs: Optional fields to set (see edit_queue)
+        :returns: ID of new queue or False when create fails
+        :raises BadRequestError: When queue already exists
+        :raises InvalidUseError: When invalid fields are set
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        valid_fields = {'Name', 'Description', 'CorrespondAddress', 'CommentAddress',
+                        'Disabled', 'SLADisabled', 'Lifecycle', 'SortOrder'
+                        }
+        invalid_fields = []
+
+        post_data = {'Name': name}
+
+        for key, val in kwargs.items():
+            if key not in valid_fields:
+                invalid_fields.append(key)
+
+            else:
+                post_data[key] = val
+
+        if invalid_fields:
+            raise InvalidUseError(f'''Unsupported names of fields: {', '.join(invalid_fields)}.''')
+
+        try:
+            res = self.__request('queue', json_data=post_data)
+        except UnexpectedResponseError as exc:  # pragma: no cover
+            if exc.status_code == 400:
+                raise rt.exceptions.BadRequestError(exc.response_message) from exc
+
+            raise
+
+        if not isinstance(res, dict):  # pragma: no cover
+            raise UnexpectedResponseError(str(res))
+
+        return int(res['id'])
+
+    def delete_queue(self, queue_id: typing.Union[str, int]) -> None:
+        """ Disable a queue.
+
+        :param queue_id: Identification of queue by name (str) or ID (int)
+
+        :returns: ID or name of edited queue or False when edit fails
+        :raises BadRequestError: When queue does not exist
+        :raises NotFoundError: If the queue does not exist
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        try:
+            _ = self.__request_delete(f'queue/{queue_id}')
+        except UnexpectedResponseError as exc:  # pragma: no cover
+            if exc.status_code == 400:
+                raise rt.exceptions.BadRequestError(exc.response_message) from exc
+
+            if exc.status_code == 204:
+                return
+
+            raise  # pragma: no cover
+
+    def get_links(self, ticket_id: typing.Union[str, int]) -> typing.Optional[typing.List[typing.Dict[str, str]]]:
+        """ Gets the ticket links for a single ticket.
+
+        Example of a return result:
+
+        .. code-block:: json
+
+            [
+                {
+                    "id": "13",
+                    "type": "ticket",
+                    "_url": "http://localhost:8080/REST/2.0/ticket/13",
+                    "ref": "depends-on"
+                }
+            ]
+
+        :param ticket_id: ticket ID
+        :returns: Links as lists of strings in dictionary with these keys
+                  (just those which are defined):
+
+                        * depends-on
+                        * depended-on-by
+                        * parent
+                        * child
+                        * refers-to
+                        * referred-to-by
+
+                  None is returned if ticket does not exist.
+        :raises UnexpectedMessageFormatError: In case that returned status code is not 200
+        """
+        ticket = self.get_ticket(ticket_id)
+
+        links = [link for link in ticket['_hyperlinks'] if link.get('type', '') == 'ticket' and link['ref'] != 'self']
+
+        return links
+
+    def edit_link(self, ticket_id: typing.Union[str, int], link_name: TYPE_VALID_TICKET_LINK_NAMES, link_value: typing.Union[str, int],
+                  delete: bool = False) -> bool:
+        """ Creates or deletes a link between the specified tickets.
+
+        :param ticket_id: ID of ticket to edit
+        :param link_name: Name of link to edit ('Parent', 'Child', 'RefersTo',
+                           'ReferredToBy', 'DependsOn', 'DependedOnBy')
+        :param link_value: Either ticker ID or external link.
+        :param delete: if True the link is deleted instead of created
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Ticket with given ID does not exist or link to delete is
+                      not found
+        :raises InvalidUseError: When none or more than one link is specified. Also,
+                            when a wrong link name is used or when trying to link to a deleted ticket.
+        """
+        if link_name not in VALID_TICKET_LINK_NAMES:
+            raise InvalidUseError(f'Unsupported link name. Use one of "{", ".join(VALID_TICKET_LINK_NAMES)}".')
+
+        if delete:
+            json_data = {f'Delete{link_name}': link_value}
+        else:
+            json_data = {f'Add{link_name}': link_value}
+
+        msg = self.__request_put(f'ticket/{ticket_id}', json_data=json_data)
+
+        if msg and isinstance(msg, list):
+            if msg[0].startswith('Couldn\'t resolve'):
+                raise NotFoundError(msg[0])
+            if 'not allowed' in msg[0]:
+                raise InvalidUseError(msg[0])
+
+        return True
+
+    def merge_ticket(self, ticket_id: typing.Union[str, int], into_id: typing.Union[str, int]) -> bool:
+        """ Merge ticket into another.
+
+        :param ticket_id: ID of ticket to be merged
+        :param into_id: ID of destination ticket
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Either origin or destination ticket does not
+                      exist or user does not have ModifyTicket permission.
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        msg = self.__request_put(f'ticket/{ticket_id}', json_data={'MergeInto': into_id})
+
+        if not isinstance(msg, list) or len(msg) != 1:  # pragma: no cover
+            raise UnexpectedResponseError(str(msg))
+
+        self.logger.debug(str(msg))
+
+        return msg[0].lower() == 'merge successful'
+
+    def take(self, ticket_id: typing.Union[str, int]) -> bool:
+        """ Take ticket
+
+        :param ticket_id: ID of ticket to be merged
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Either the ticket does not exist or user does not
+                      have TakeTicket permission.
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        msg = self.__request_put(f'ticket/{ticket_id}/take')
+
+        if not isinstance(msg, list) or len(msg) != 1:  # pragma: no cover
+            raise UnexpectedResponseError(str(msg))
+
+        self.logger.debug(str(msg))
+
+        return msg[0].lower().startswith('owner changed')
+
+    def untake(self, ticket_id: typing.Union[str, int]) -> bool:
+        """ Untake ticket
+
+        :param ticket_id: ID of ticket to be merged
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Either the ticket does not exist or user does not
+                      own the ticket.
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        msg = self.__request_put(f'ticket/{ticket_id}/untake')
+
+        if not isinstance(msg, list) or len(msg) != 1:  # pragma: no cover
+            raise UnexpectedResponseError(str(msg))
+
+        self.logger.debug(str(msg))
+
+        return msg[0].lower().startswith('owner changed')
+
+    def steal(self, ticket_id: typing.Union[str, int]) -> bool:
+        """ Steal ticket
+
+        :param ticket_id: ID of ticket to be merged
+        :returns: ``True``
+                      Operation was successful
+                  ``False``
+                      Either the ticket does not exist or user does not
+                      have StealTicket permission.
+        :raises UnexpectedResponseError: If the response from RT is not as expected
+        """
+        msg = self.__request_put(f'ticket/{ticket_id}/steal')
+
+        if not isinstance(msg, list) or len(msg) != 1:  # pragma: no cover
+            raise UnexpectedResponseError(str(msg))
+
+        self.logger.debug(str(msg))
+
+        return msg[0].lower().startswith('owner changed')
diff --git a/setup.cfg b/setup.cfg
index 1a00009..bb97348 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -21,14 +21,21 @@ classifiers =
     Programming Language :: Python :: 3 :: Only
     Programming Language :: Python :: 3
     Topic :: Software Development :: Libraries :: Python Modules
+project_urls =
+    Documentation = https://python-rt.readthedocs.io/
+    Source = https://github.com/python-rt/python-rt
+    Tracker = https://github.com/python-rt/python-rt/issues
+    Changelog = https://github.com/python-rt/python-rt/blob/master/CHANGELOG.md
 
 [options]
 zip_safe = False
 packages = rt
 include_package_data = True
-python_requires = >= 3.5
+python_requires = >= 3.7
 install_requires =
     requests
+    requests_toolbelt
+    typing-extensions ; python_version < "3.8"
 
 [options.packages.find]
 exclude =
@@ -42,6 +49,8 @@ docs =
     sphinx
     sphinx-autodoc-typehints
     sphinx-rtd-theme
+    furo
+    sphinx-copybutton
 dev =
     pycodestyle
     pylint
@@ -64,5 +73,5 @@ ignore = E501, W503, E124, E126
 
 
 [flake8]
-max-line-length = 160
-ignore = D210, D400, W503, D205, D209, D401, E126, E124
+max-line-length = 240
+ignore = D210, D400, W503, D205, D209, D401, E126, E124, N803


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index e69de29..5dad06d 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -0,0 +1,7 @@
+import random
+import string
+
+
+def random_string(length: int = 15) -> str:
+    """Return a random string."""
+    return ''.join([random.choice(string.ascii_letters) for i in range(length)])
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..2ea3b5f
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,19 @@
+import pytest
+import requests.auth
+import typing
+
+if typing.TYPE_CHECKING:
+    import rt.rest2
+
+# blank setup defaults
+RT_URL = 'http://localhost:8080/REST/2.0/'
+RT_USER = 'root'
+RT_PASSWORD = 'password'
+RT_QUEUE = 'General'
+
+
+@pytest.fixture(scope='session')
+def rt_connection() -> 'rt.rest2.Rt':
+    """Setup a generic connection."""
+    import rt.rest2
+    return rt.rest2.Rt(url=RT_URL, http_auth=requests.auth.HTTPBasicAuth(RT_USER, RT_PASSWORD), http_timeout=None)
diff --git a/tests/test_basic.py b/tests/test_basic.py
new file mode 100644
index 0000000..84b0350
--- /dev/null
+++ b/tests/test_basic.py
@@ -0,0 +1,403 @@
+"""Tests for python-rt / REST2 - Python interface to Request Tracker :term:`API`."""
+
+__license__ = """ Copyright (C) 2013 CZ.NIC, z.s.p.o.
+    Copyright (c) 2021 CERT Gouvernemental (GOVCERT.LU)
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+__docformat__ = "reStructuredText en"
+__authors__ = [
+    '"Jiri Machalek" <jiri.machalek@nirt_connection.cz>',
+    '"Georges Toth" <georges.toth@govcert.etat.lu>',
+]
+
+import base64
+import random
+import string
+import typing
+
+import pytest
+import requests.auth
+
+import rt.exceptions
+import rt.rest2
+from . import random_string
+
+RT_URL = 'http://localhost:8080/REST/2.0/'
+RT_USER = 'root'
+RT_PASSWORD = 'password'
+RT_QUEUE = 'General'
+
+
+def test_get_user(rt_connection: rt.rest2.Rt):
+    user = rt_connection.get_user(RT_USER)
+    assert user['Name'] == RT_USER
+    assert '@' in user['EmailAddress']
+    assert user['Privileged'] == 1
+
+
+def test_invalid_api_url():
+    with pytest.raises(ValueError):
+        rt_connection = rt.rest2.Rt(url='https://example.com', http_auth=requests.auth.HTTPBasicAuth('dummy', 'dummy'))
+
+
+def test_ticket_operations(rt_connection: rt.rest2.Rt):
+    ticket_subject = f'Testing issue {random_string()}'
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+
+    # empty search result
+    search_result = list(rt_connection.search(Subject=ticket_subject))
+    assert not len(search_result)
+
+    # create
+    ticket_id = rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, queue=RT_QUEUE)
+    assert ticket_id > -1
+
+    # search
+    search_result = list(rt_connection.search(Subject=ticket_subject))
+    assert len(search_result) == 1
+    assert search_result[0]['id'] == str(ticket_id)
+    assert search_result[0]['Status'] == 'new'
+
+    # raw search
+    search_result = list(rt_connection.search(raw_query=f'Subject="{ticket_subject}"'))
+    assert len(search_result) == 1
+    assert search_result[0]['id'] == str(ticket_id)
+    assert search_result[0]['Status'] == 'new'
+
+    # get ticket
+    ticket = rt_connection.get_ticket(ticket_id)
+    search_result[0]['id'] = int(search_result[0]['id'])
+
+    for k in search_result[0]:
+        if k.startswith('_') or k in ('type', 'CustomFields'):
+            continue
+
+        assert k in ticket
+        assert ticket[k] == search_result[0][k]
+
+    # edit ticket
+    requestors = ['tester1@example.com', 'tester2@example.com']
+    rt_connection.edit_ticket(ticket_id, Status='open', Requestor=requestors)
+
+    # get ticket (edited)
+    ticket = rt_connection.get_ticket(ticket_id)
+    assert ticket['Status'] == 'open'
+    for requestor in ticket['Requestor']:
+        assert requestor['id'] in requestors
+    for requestor in requestors:
+        found = False
+        for _requestor in ticket['Requestor']:
+            if _requestor['id'] == requestor:
+                found = True
+                break
+
+        assert found
+
+    # get history
+    hist = rt_connection.get_ticket_history(ticket_id)
+    assert len(hist) > 0
+    transaction = rt_connection.get_transaction(hist[0]['id'])
+    found = False
+    for hyperlink in transaction['_hyperlinks']:
+        if hyperlink['ref'] == 'attachment':
+            attachment_id = hyperlink['_url'].rsplit('/', 1)[1]
+            attachment = base64.b64decode(rt_connection.get_attachment(attachment_id)['Content']).decode('utf-8')
+            found = True
+            assert attachment == ticket_text
+            break
+
+    assert found
+
+    # get_short_history
+    short_hist = rt_connection.get_ticket_history(ticket_id)
+    assert len(short_hist) > 0
+    assert short_hist[0]['Type'] == 'Create'
+    assert short_hist[0]['Creator']['Name'] == RT_USER
+
+    # create 2nd ticket
+    ticket2_subject = 'Testing issue ' + "".join([random.choice(string.ascii_letters) for i in range(15)])
+    ticket2_id = rt_connection.create_ticket(queue=RT_QUEUE, subject=ticket2_subject)
+    assert ticket2_id > -1
+
+    # edit link
+    assert rt_connection.edit_link(ticket_id, 'DependsOn', ticket2_id)
+
+    # get links
+    links1 = rt_connection.get_links(ticket_id)
+    found = False
+    for link in links1:
+        if link['ref'] == 'depends-on' and link['id'] == str(ticket2_id):
+            found = True
+    assert found
+
+    links2 = rt_connection.get_links(ticket2_id)
+    found = False
+    for link in links2:
+        if link['ref'] == 'depended-on-by' and link['id'] == str(ticket_id):
+            found = True
+    assert found
+
+    # reply with attachment
+    attachment_content = b'Content of attachment.'
+    attachment_name = 'attachment-name.txt'
+    reply_text = 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'
+    attachment = rt.rest2.Attachment(attachment_name, 'text/plain', attachment_content)
+    assert rt_connection.reply(ticket_id, content=reply_text, attachments=[attachment])
+
+    # reply with a comment
+    reply_text = 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'
+    assert rt_connection.comment(ticket_id, content=reply_text)
+
+    # reply with a html content
+    reply_text = '<em>Ut enim ad minim veniam</em>, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.'
+    assert rt_connection.reply(ticket_id, content=reply_text, content_type='text/html')
+
+    # attachments list
+    at_list = rt_connection.get_attachments(ticket_id)
+    assert at_list
+    at_names = [at['Filename'] for at in at_list]
+    assert attachment_name in at_names
+    # get the attachment and compare it's content
+    at_id = at_list[at_names.index(attachment_name)]['id']
+    at_content = base64.b64decode(rt_connection.get_attachment(at_id)['Content'])
+    assert at_content == attachment_content
+
+    # merge tickets
+    assert rt_connection.merge_ticket(ticket2_id, ticket_id)
+
+    # delete ticket
+    assert rt_connection.edit_ticket(ticket_id, Status='deleted')
+
+
+def test_attachments_create(rt_connection: rt.rest2.Rt):
+    """Create a ticket with a random (>= 2) number of attachments and verify that they have been successfully added to the ticket."""
+    ticket_subject = f'Testing issue {random_string()}'
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+
+    attachment_count = random.randint(2, 10)
+    attachments = []
+    for i in range(attachment_count):
+        attachment_content = random_string(length=100).encode()
+        attachment_name = f'attachment-{random_string(length=10)}.txt'
+        attachments.append(rt.rest2.Attachment(attachment_name, 'text/plain', attachment_content))
+
+    # create
+    ticket_id = rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, queue=RT_QUEUE, attachments=attachments)
+    assert ticket_id > -1
+
+    # get ticket
+    ticket = rt_connection.get_ticket(ticket_id)
+    assert int(ticket['id']) == ticket_id
+
+    # attachments list
+    at_list = rt_connection.get_attachments(ticket_id)
+    assert at_list
+    assert len(at_list) == len(attachments)
+    at_names = [at['Filename'] for at in at_list]
+    for k in attachments:
+        assert k.file_name in at_names
+
+        # get the attachment and compare it's content
+        at_id = at_list[at_names.index(k.file_name)]['id']
+        at_content = base64.b64decode(rt_connection.get_attachment(at_id)['Content'])
+        assert at_content == k.file_content
+
+
+def test_attachments_comment(rt_connection: rt.rest2.Rt):
+    """Create a ticket and comment to it with a random (>= 2) number of attachments and verify that they have been successfully added to the ticket."""
+    ticket_subject = f'Testing issue {random_string()}'
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+
+    # create
+    ticket_id = rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, queue=RT_QUEUE)
+    assert ticket_id > -1
+
+    attachment_count = random.randint(2, 10)
+    attachments = []
+    for i in range(attachment_count):
+        attachment_content = random_string(length=100).encode()
+        attachment_name = f'attachment-{random_string(length=10)}.txt'
+        attachments.append(rt.rest2.Attachment(attachment_name, 'text/plain', attachment_content))
+
+    # comment with attachments
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+    comment_success = rt_connection.comment(ticket_id=ticket_id, content=ticket_text, attachments=attachments)
+    assert comment_success
+
+    # attachments list
+    at_list = rt_connection.get_attachments(ticket_id)
+    assert at_list
+    assert len(at_list) == len(attachments)
+    at_names = [at['Filename'] for at in at_list]
+    for k in attachments:
+        assert k.file_name in at_names
+
+        # get the attachment and compare it's content
+        at_id = at_list[at_names.index(k.file_name)]['id']
+        at_content = base64.b64decode(rt_connection.get_attachment(at_id)['Content'])
+        assert at_content == k.file_content
+
+
+def test_attachments_reply(rt_connection: rt.rest2.Rt):
+    """Create a ticket and reply to it with a random (>= 2) number of attachments and verify that they have been successfully added to the ticket."""
+    ticket_subject = f'Testing issue {random_string()}'
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+
+    # create
+    ticket_id = rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, queue=RT_QUEUE)
+    assert ticket_id > -1
+
+    attachment_count = random.randint(2, 10)
+    attachments = []
+    for i in range(attachment_count):
+        attachment_content = random_string(length=100).encode()
+        attachment_name = f'attachment-{random_string(length=10)}.txt'
+        attachments.append(rt.rest2.Attachment(attachment_name, 'text/plain', attachment_content))
+
+    # comment with attachments
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+    comment_success = rt_connection.reply(ticket_id=ticket_id, content=ticket_text, attachments=attachments)
+    assert comment_success
+
+    # attachments list
+    at_list = rt_connection.get_attachments(ticket_id)
+    assert at_list
+    assert len(at_list) == len(attachments)
+    at_names = [at['Filename'] for at in at_list]
+    for k in attachments:
+        assert k.file_name in at_names
+
+        # get the attachment and compare it's content
+        at_id = at_list[at_names.index(k.file_name)]['id']
+        at_content = base64.b64decode(rt_connection.get_attachment(at_id)['Content'])
+        assert at_content == k.file_content
+
+
+def test_ticket_operations_admincc_cc(rt_connection: rt.rest2.Rt):
+    ticket_subject = f'Testing issue {random_string()}'
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+
+    def compare_list(from_list: typing.List[str], ticket_list: typing.List[dict]) -> bool:
+        """Lists (Requestor, AdminCc, Cc) returned from REST2 contain a list of dicts with additional user information.
+
+        Thus a simple compare of both lists is not enough.
+        """
+        if len(from_list) != len(ticket_list):
+            return False
+
+        to_list = [entry['id'] for entry in ticket_list]
+        diff = set(from_list) ^ set(to_list)
+
+        return not diff
+
+    ticket_id = rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, queue=RT_QUEUE)
+    assert ticket_id > -1
+
+    # make sure Requestor, AdminCc and Cc are present and an empty list, as would be expected
+    ticket = rt_connection.get_ticket(ticket_id)
+    assert len(ticket['Requestor']) >= 0
+    assert len(ticket['AdminCc']) >= 0
+    assert len(ticket['Cc']) >= 0
+
+    # set requestors
+    requestors = ['tester1@example.com', 'tester2@example.com']
+    rt_connection.edit_ticket(ticket_id, Status='open', Requestor=requestors)
+    # verify
+    ticket = rt_connection.get_ticket(ticket_id)
+    assert compare_list(requestors, ticket['Requestor'])
+
+    # set admincc
+    admincc = ['tester2@example.com', 'tester3@example.com']
+    rt_connection.edit_ticket(ticket_id, Status='open', AdminCc=admincc)
+    # verify
+    ticket = rt_connection.get_ticket(ticket_id)
+    assert compare_list(requestors, ticket['Requestor'])
+    assert compare_list(admincc, ticket['AdminCc'])
+
+    # update admincc
+    admincc = ['tester2@example.com', 'tester3@example.com', 'tester4@example.com']
+    rt_connection.edit_ticket(ticket_id, Status='open', AdminCc=admincc)
+    # verify
+    ticket = rt_connection.get_ticket(ticket_id)
+    assert compare_list(requestors, ticket['Requestor'])
+    assert compare_list(admincc, ticket['AdminCc'])
+
+    # unset requestors and admincc
+    requestors = []
+    admincc = []
+    rt_connection.edit_ticket(ticket_id, Status='open', Requestor=requestors, AdminCc=admincc)
+    # verify
+    ticket = rt_connection.get_ticket(ticket_id)
+    assert compare_list(requestors, ticket['Requestor'])
+    assert compare_list(admincc, ticket['AdminCc'])
+
+
+def test_users(rt_connection: rt.rest2.Rt):
+    assert rt_connection.get_user('tester1@example.com') is not None
+    assert rt_connection.user_exists('root', privileged=True) is True
+    assert rt_connection.user_exists('tester1@example.com', privileged=False) is True
+    assert rt_connection.user_exists('tester1@example.com', privileged=True) is False
+    assert rt_connection.user_exists('does-not-exist@example.com') is False
+
+    random_user_name = f'username_{random_string()}'
+    random_user_email = f'user-{random_string()}@example.com'
+    random_user_password = f'user_password-{random_string()}'
+
+    assert rt_connection.create_user(random_user_name, random_user_email, Password=random_user_password) == random_user_name
+    rt_connection.delete_user(random_user_name)
+
+    with pytest.raises(rt.exceptions.NotFoundError):
+        rt_connection.delete_user(f'username_{random_string()}')
+
+    assert rt_connection.edit_user(user_id=random_user_name, RealName=random_user_name)
+
+
+def test_queues(rt_connection: rt.rest2.Rt):
+    queue = rt_connection.get_queue(RT_QUEUE)
+    assert queue['Name'] == RT_QUEUE
+
+    queues = rt_connection.get_all_queues()
+    assert len(queues) >= 1
+
+    found = False
+    for q in queues:
+        if q['Name'] == RT_QUEUE:
+            found = True
+    assert found
+
+    random_queue_name = f'Queue {random_string()}'
+    random_queue_email = f'q-{random_string()}@example.com'
+
+    rt_connection.create_queue(random_queue_name, CorrespondAddress=random_queue_email, Description='test queue')
+    queues = rt_connection.get_all_queues()
+    found = False
+    for q in queues:
+        if q['Name'] == random_queue_name:
+            found = True
+    assert found
+
+    rt_connection.edit_queue(random_queue_name, Disabled="1")
+    assert rt_connection.get_queue(random_queue_name)['Disabled'] == "1"
+
+    rt_connection.edit_queue(random_queue_name, Disabled="0")
+    assert rt_connection.get_queue(random_queue_name)['Disabled'] == "0"
+
+    with pytest.raises(rt.exceptions.NotFoundError):
+        rt_connection.get_queue('InvalidName')
+
+    rt_connection.delete_queue(random_queue_name)
+
+    with pytest.raises(rt.exceptions.NotFoundError):
+        rt_connection.delete_queue(f'Queue {random_string()}')
diff --git a/tests/test_rest1.py b/tests/test_rest1.py
index 49ee926..a8753d2 100644
--- a/tests/test_rest1.py
+++ b/tests/test_rest1.py
@@ -24,7 +24,7 @@
 import string
 import unittest
 
-import rt
+import rt.rest1
 
 
 class RtTestCase(unittest.TestCase):
@@ -83,19 +83,19 @@ def _have_creds(*creds_seq):
                          "missing credentials required to run test")
     def test_login_and_logout(self):
         for name in self.RT_VALID_CREDENTIALS:
-            tracker = rt.Rt(self.RT_VALID_CREDENTIALS[name]['url'], **self.RT_VALID_CREDENTIALS[name]['support'])
+            tracker = rt.rest1.Rt(self.RT_VALID_CREDENTIALS[name]['url'], **self.RT_VALID_CREDENTIALS[name]['support'])
             self.assertTrue(tracker.login(), 'Invalid login to RT demo site ' + name)
             self.assertTrue(tracker.logout(), 'Invalid logout from RT demo site ' + name)
         for name, params in self.RT_INVALID_CREDENTIALS.items():
-            tracker = rt.Rt(**params)
+            tracker = rt.rest1.Rt(**params)
             self.assertFalse(tracker.login(), 'Login to RT demo site ' + name + ' should fail but did not')
-            self.assertRaises(rt.AuthorizationError, lambda: tracker.search())
+            self.assertRaises(rt.exceptions.AuthorizationError, lambda: tracker.search())
         for name, params in self.RT_MISSING_CREDENTIALS.items():
-            tracker = rt.Rt(**params)
-            self.assertRaises(rt.AuthorizationError, lambda: tracker.login())
+            tracker = rt.rest1.Rt(**params)
+            self.assertRaises(rt.exceptions.AuthorizationError, lambda: tracker.login())
         for name, params in self.RT_BAD_URL.items():
-            tracker = rt.Rt(**params)
-            self.assertRaises(rt.UnexpectedResponse, lambda: tracker.login())
+            tracker = rt.rest1.Rt(**params)
+            self.assertRaises(rt.exceptions.UnexpectedResponseError, lambda: tracker.login())
 
     @unittest.skipUnless(_have_creds(RT_VALID_CREDENTIALS),
                          "missing credentials required to run test")
@@ -106,7 +106,7 @@ def test_ticket_operations(self):
             url = self.RT_VALID_CREDENTIALS[name]['url']
             default_login = self.RT_VALID_CREDENTIALS[name]['support']['default_login']
             default_password = self.RT_VALID_CREDENTIALS[name]['support']['default_password']
-            tracker = rt.Rt(url, default_login=default_login, default_password=default_password)
+            tracker = rt.rest1.Rt(url, default_login=default_login, default_password=default_password)
             self.assertTrue(tracker.login(), 'Invalid login to RT demo site ' + name)
             # empty search result
             search_result = tracker.search(Subject=ticket_subject)
@@ -120,7 +120,7 @@ def test_ticket_operations(self):
             self.assertEqual(search_result[0]['id'], 'ticket/' + str(ticket_id), 'Bad id in search result of just created ticket.')
             self.assertEqual(search_result[0]['Status'], 'new', 'Bad status in search result of just created ticket.')
             # search all queues
-            search_result = tracker.search(Queue=rt.ALL_QUEUES, Subject=ticket_subject)
+            search_result = tracker.search(Queue=rt.rest1.ALL_QUEUES, Subject=ticket_subject)
             self.assertEqual(search_result[0]['id'], 'ticket/' + str(ticket_id), 'Bad id in search result of just created ticket.')
             # raw search
             search_result = tracker.search(raw_query='Subject="{}"'.format(ticket_subject))
@@ -128,7 +128,7 @@ def test_ticket_operations(self):
             self.assertEqual(search_result[0]['id'], 'ticket/' + str(ticket_id), 'Bad id in search result of just created ticket.')
             self.assertEqual(search_result[0]['Status'], 'new', 'Bad status in search result of just created ticket.')
             # raw search all queues
-            search_result = tracker.search(Queue=rt.ALL_QUEUES, raw_query='Subject="{}"'.format(ticket_subject))
+            search_result = tracker.search(Queue=rt.rest1.ALL_QUEUES, raw_query='Subject="{}"'.format(ticket_subject))
             self.assertEqual(search_result[0]['id'], 'ticket/' + str(ticket_id), 'Bad id in search result of just created ticket.')
             # get ticket
             ticket = tracker.get_ticket(ticket_id)
@@ -195,7 +195,7 @@ def test_ticket_operations_admincc_cc(self):
             url = self.RT_VALID_CREDENTIALS[name]['url']
             default_login = self.RT_VALID_CREDENTIALS[name]['support']['default_login']
             default_password = self.RT_VALID_CREDENTIALS[name]['support']['default_password']
-            tracker = rt.Rt(url, default_login=default_login, default_password=default_password)
+            tracker = rt.rest1.Rt(url, default_login=default_login, default_password=default_password)
             self.assertTrue(tracker.login(), 'Invalid login to RT demo site ' + name)
 
             ticket_id = tracker.create_ticket(Subject=ticket_subject, Text=ticket_text)
diff --git a/tests/test_tickets.py b/tests/test_tickets.py
new file mode 100644
index 0000000..7dafda2
--- /dev/null
+++ b/tests/test_tickets.py
@@ -0,0 +1,67 @@
+"""Tests for python-rt / REST2 - Python interface to Request Tracker :term:`API`."""
+
+__license__ = """ Copyright (C) 2013 CZ.NIC, z.s.p.o.
+    Copyright (c) 2021 CERT Gouvernemental (GOVCERT.LU)
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+"""
+__docformat__ = "reStructuredText en"
+__authors__ = [
+    '"Jiri Machalek" <jiri.machalek@nic.cz>',
+    '"Georges Toth" <georges.toth@govcert.etat.lu>',
+]
+
+import base64
+
+import rt.rest2
+from . import random_string
+from .conftest import RT_QUEUE
+
+
+def test_ticket_attachments(rt_connection: rt.rest2.Rt):
+    """Test various ticket attachment operations."""
+    ticket_subject = f'Testing issue {random_string()}'
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+    attachment_content = b'Content of attachment.'
+    attachment_name = 'attachment-name.txt'
+
+    attachment = rt.rest2.Attachment(attachment_name, 'text/plain', attachment_content)
+    ticket_id = rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, queue=RT_QUEUE, attachments=[attachment])
+    assert ticket_id
+
+    att_ids = rt_connection.get_attachments_ids(ticket_id)
+    assert len(att_ids) == 1
+
+    att_list = rt_connection.get_attachments(ticket_id)
+    assert len(att_list) == 1
+
+    att_names = [att['Filename'] for att in att_list]
+    assert attachment_name in att_names
+
+    # get the attachment and compare it's content
+    att_id = att_list[att_names.index(attachment_name)]['id']
+    att_content = base64.b64decode(rt_connection.get_attachment(att_id)['Content'])
+    assert att_content == attachment_content
+
+
+def test_ticket_take(rt_connection: rt.rest2.Rt):
+    """Test take/untake."""
+    ticket_subject = f'Testing issue {random_string()}'
+    ticket_text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
+
+    ticket_id = rt_connection.create_ticket(subject=ticket_subject, content=ticket_text, queue=RT_QUEUE)
+    assert ticket_id
+
+    assert rt_connection.take(ticket_id)
+    assert rt_connection.untake(ticket_id)