Repository: andr1an/nic-api (language: python)

Issue:
Non-ASCII domain names support
According to the [docs](https://www.nic.ru/help/domeny-na-nacional6nyh-yazykah-idn_7942.html), NIC.RU should support non-ASCII domain names in several zones, but this functionality was never tested.

Need to verify how the library works with at least Cyrillic domain names – both TLDs and second+ level, fix the possible issues and update at least docstrings to provide info about `name` and `idn_name` attributes/parameters.
Migrate from Travis CI to GitHub Actions
Closes #34.


Discussion / hints:

Fix patch:
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 4bb654f..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-language: python
-
-matrix:
-  include:
-    - python: 2.7
-      dist: trusty
-    - python: 3.4
-      dist: trusty
-    - python: 3.5
-      dist: trusty
-    - python: 3.6
-      dist: trusty
-    - python: 3.7
-      dist: xenial
-    - python: 3.8
-      dist: bionic
-    - python: 3.9
-      dist: bionic
-    - python: 3.10
-      dist: bionic
-    - python: 3.11
-      dist: bionic
-
-install: |
-  set -e
-  pip install --upgrade pip setuptools importlib-metadata
-  pip install .
-  if python -c 'import sys; 0 if sys.version_info >= (3, 6) else sys.exit(1)'
-  then
-    pip install black
-  fi
-  pip install pytest
-
-script: |
-  set -e
-  if python -c 'import sys; 0 if sys.version_info >= (3, 6) else sys.exit(1)'
-  then
-    black --line-length 80 --check --diff .
-  fi
-  py.test
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..36523f4
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,33 @@
+# How to contribute to nic-api
+
+Firstly thank you for considering contributing to the project!
+
+## Support questions
+
+As the project is not very popular, you are free to use GitHub issues for
+asking any related questions.
+
+## Reporting issues
+
+Include the following information in your post:
+
+* Describe what you expected to happen;
+* If possible, give info about your tariff in NIC.RU;
+* If you are getting a parsing error, please include a raw XML response;
+* Describe what actually happened; include the full traceback if there was an
+ exception;
+* List your Python, `nic-api`, `requests` and `requests-oauthlib` versions.
+
+## Submitting patches
+
+If there is not an open issue for what you want to submit, prefer opening one
+for discussion before working on a PR. You can work on any issue that doesn't
+have an open PR linked to it or a maintainer assigned to it. These show up in
+the sidebar. No need to ask if you can work on an issue that interests you.
+
+Include the following in your patch:
+
+* Use [black](https://black.readthedocs.io) to format your code;
+* If the issue does not require accessing the API, include tests;
+* Update any relevant docs pages and docstrings. Docs pages and docstrings
+ should be wrapped at 80 characters.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e526026
--- /dev/null
+++ b/README.md
@@ -0,0 +1,165 @@
+# NIC.RU API Python library
+
+This library provides interfaces for managing DNS zones and records for the DNS
+service NIC.RU (Ru-Center).
+
+## Installation
+
+Using `pip`:
+
+```shell
+pip install nic-api
+```
+
+If you want to use this module in your project, add `nic-api` to its
+dependencies.
+
+## Usage
+
+### Initialization
+
+To start using the API, you should get an OAuth application login and a
+password from NIC.RU. Here is the registration page:
+https://www.nic.ru/manager/oauth.cgi?step=oauth.app_register
+
+Create an instance of `nic_api.DnsApi` and provide the obtained credentials:
+
+```python
+from nic_api import DnsApi
+app_login = "your_application_login"
+app_password = "your_application_secret"
+api = DnsApi(app_login, app_password)
+```
+
+### Authentication
+
+Call the `DnsApi.get_token()` method with the username and the password for
+your NIC.RU account:
+
+```python
+api.get_token(
+    username="Your_account/NIC-D",
+    password="Your_password",
+)
+```
+
+Now you are ready to use the API.
+
+Till the token is valid, you don't need to provide neither client username or
+password to access the API – just create an instance of the `DnsApi` class
+with the same OAuth config, and pass the cached token as `token` parameter:
+
+```python
+api = DnsApi(app_login, app_password, token)
+```
+
+If you have a valid refresh token, you can get a new access token with it:
+
+```python
+api.refresh_token(refresh_token)
+```
+
+You can add a callback method to save the token somewhere outside of the
+`DnsApi` object. Use the `token_updater_clb` parameter for that.
+
+### Viewing services and DNS zones
+
+In the NIC.RU, DNS zones are located in “services”:
+
+```python
+api.services()
+```
+
+Usually there is only one service per account. To view available zones in the
+service `MY_SERVICE` call `DnsApi.zones()`:
+
+```python
+api.zones("MY_SERVICE")
+```
+
+**Always check if there are any uncommitted changes in the zone before making
+any modifications – your commit would apply all unsaved changes!**
+
+### Getting DNS records
+
+For viewing or modifying records, you need to specify both service and DNS
+zone name:
+
+```python
+api.records("MY_SERIVCE", "example.com")
+```
+
+### Creating a record
+
+To add a record, create an instance of one of the `nic_api.models.DNSRecord`
+subclasses, i.e. `ARecord`:
+
+```python
+from nic_api.models import ARecord
+record_www = ARecord(name="www", a="8.8.8.8", ttl=3600)
+```
+
+Add this record to the zone and commit the changes:
+
+```python
+api.add_record(record_www, "MY_SERVICE", "example.com")
+api.commit("MY_SERVICE", "example.com")
+```
+
+### Deleting a record
+
+Every record in the zone has an unique ID, and it's accessible via
+`DNSRecord.id` property. When you got the ID, pass it to the
+`DnsApi.delete_record()` method:
+
+```python
+api.delete_record(100000, "MY_SERVICE", "example.com")
+api.commit("MY_SERVICE", "example.com")
+```
+
+Do not forget to commit the changes afterwards.
+
+### Default service and zone
+
+The `service` and `zone` parameters can be optional in all `DnsApi`
+methods, if you set `default_service` and `default_zone` properties:
+
+```python
+api.default_service = "MY_SERVICE"
+api.default_zone = "example.com"
+
+api.delete_record(100000)  # service or zone are not required now
+api.commit()               # the same for commit() method
+```
+
+### Internationalized domain names
+
+For using IDNs, you need to encode parameters with Punycode before passing them
+to the classes or methods, with the only exception of `idn_name` argument. In
+Python 3.7+ this is done via calling `.encode("idna").decode()` methods on the
+`str` object:
+
+```python
+record_idn = ARecord(a="192.168.0.1", name="тест".encode("idna").decode())
+api.add_record(record_idn, "MY_SERVICE", "example.com")
+api.commit("MY_SERVICE", "example.com")
+```
+
+For working with top-level IDNs, use the same approach:
+
+```python
+api.records("MY_SERIVCE", "мой-домен.рф".encode("idna").decode())
+
+# or:
+api.default_service = "MY_SERVICE"
+api.default_zone = "мой-домен.рф".encode("idna").decode()
+api.records()
+```
+
+## Note for Python old versions' users
+
+Python 2.7 was EOLed on 2020-01-01, and Python 3.7 is going to be EOLed on
+2023-06-27, so the 1.x.x release branch of `nic-api` is going to only support 
+active Python releases. If you still need old Python version support, please
+use 0.x.x releases of `nic-api`. These versions would still receive bugfixes,
+but no enhancements.
diff --git a/README.rst b/README.rst
deleted file mode 100644
index 6027476..0000000
--- a/README.rst
+++ /dev/null
@@ -1,145 +0,0 @@
-NIC.RU API wrapper library
-==========================
-
-The package is a wrapper for the API of Russian DNS registrar Ru-Center
-(a.k.a. NIC.RU). The library provides classes for managing DNS services,
-zones and records.
-
-Installation
-------------
-
-Using ``pip``::
-
-    pip install nic-api
-
-If you want to use the module in your project, add this line to the project's
-``requirements.txt`` file::
-
-    nic-api
-
-Usage
------
-
-Initialization
-~~~~~~~~~~~~~~
-
-To start using the API, you should get a pair of OAuth application login and
-password from NIC.RU. Here is the registration page:
-https://www.nic.ru/manager/oauth.cgi?step=oauth.app_register
-
-Create an instance of ``nic_api.DnsApi`` and provide the OAuth application
-credentials:
-
-.. code:: python
-
-    from nic_api import DnsApi
-    app_login = "your_application_login"
-    app_password = "your_application_secret"
-    api = DnsApi(app_login, app_password)
-
-Authorization
-~~~~~~~~~~~~~
-
-Call the ``get_token()`` method and specify the username and the password for
-your NIC.RU account:
-
-.. code:: python
-
-    api.get_token(
-        username="Your_account/NIC-D",
-        password="Your_password",
-    )
-
-Now you are ready to use the API.
-
-While the token is valid, you don't need to provide neither client username or
-password to access the API – just create an instance of the ``DnsApi`` class
-with the same OAuth config, and pass the cached token as ``token`` parameter:
-
-.. code:: python
-
-    api = DnsApi(app_login, app_password, token)
-
-If you have a valid refresh token, you can get a new access token with it:
-
-.. code:: python
-
-    api.refresh_token(refresh_token)
-
-You can add a callback to cache the token with ``token_updater_clb`` parameter
-of the class constructor.
-
-Viewing services and DNS zones
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-In the NIC.RU, DNS zones are located in "services":
-
-.. code:: python
-
-    api.services()
-
-Usually, there is one service per account. Let's view available zones in the
-service ``MY_SERVICE``:
-
-.. code:: python
-
-    api.zones("MY_SERVICE")
-
-**Always check if the zone has any uncommitted changes to it before
-making any modifications - your commit will apply other changes too!**
-
-Getting DNS records
-~~~~~~~~~~~~~~~~~~~
-
-For viewing or modifying records, you need to specify both service and DNS
-zone name:
-
-.. code:: python
-
-    api.records("MY_SERIVCE", "example.com")
-
-Creating a record
-~~~~~~~~~~~~~~~~~
-
-To add a record, create an instance of one of the ``nic_api.models.DNSRecord``
-subclasses, i.e. ``ARecord``:
-
-.. code:: python
-
-    from nic_api.models import ARecord
-    record_www = ARecord(name="www", a="8.8.8.8", ttl=3600)
-
-Add this record to the zone and commit the changes:
-
-.. code:: python
-
-    api.add_record(record_www, "MY_SERVICE", "example.com")
-    api.commit("MY_SERVICE", "example.com")
-
-Deleting a record
-~~~~~~~~~~~~~~~~~
-
-Every record in the zone has an unique ID, and it's accessible via
-``DNSRecord.id`` property. When you got the ID, pass it to the
-``delete_record`` method:
-
-.. code:: python
-
-    api.delete_record(100000, "MY_SERVICE", "example.com")
-    api.commit("MY_SERVICE", "example.com")
-
-Do not forget to always commit the changes!
-
-Default service and zone
-~~~~~~~~~~~~~~~~~~~~~~~~
-
-The ``service`` and ``zone`` parameters can be optional in all ``DnsApi``
-methods, if you set ``default_service`` and ``default_zone`` properties:
-
-.. code:: python
-
-    api.default_service = "MY_SERVICE"
-    api.default_zone = "example.com"
-
-    api.delete_record(100000)  # service or zone are not needed
-    api.commit()               # the same for commit() method
diff --git a/nic_api/compat.py b/nic_api/compat.py
deleted file mode 100644
index 749a329..0000000
--- a/nic_api/compat.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Python 2.7 compatibility"""
-
-from sys import version_info
-
-
-XML_ENCODING = "utf-8" if version_info.major < 3 else "unicode"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..2e102ee
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,44 @@
+[project]
+name = "nic_api"
+version = "1.0.0"
+authors = [
+    { name="Sergey Andrianov", email="info@andrian.ninja" },
+]
+description = "NIC.RU API Python library"
+readme = "README.md"
+requires-python = ">=3.8"
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "Intended Audience :: System Administrators",
+    "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
+    "Operating System :: OS Independent",
+    "Programming Language :: Python :: 3 :: Only",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python",
+    "Topic :: Internet :: Name Service (DNS)",
+    "Topic :: Software Development :: Libraries :: Python Modules",
+]
+dependencies = [
+    "requests>=2.4",
+    "requests-oauthlib>=1.1",
+]
+
+[project.urls]
+"Homepage" = "https://github.com/andr1an/nic-api"
+"Bug Tracker" = "https://github.com/andr1an/nic-api/issues"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project.optional-dependencies]
+dev = [
+    "pytest",
+    "black",
+]
+
+[tool.black]
+line-length = 80
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index 3c6e79c..0000000
--- a/setup.cfg
+++ /dev/null
@@ -1,2 +0,0 @@
-[bdist_wheel]
-universal=1
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 168b16d..0000000
--- a/setup.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import os
-
-from setuptools import setup
-
-here = os.path.abspath(os.path.dirname(__file__))
-
-long_description = open(os.path.join(here, "README.rst"), "r").read()
-
-setup(
-    name="nic_api",
-    version="0.4.2",
-    description="NIC.RU API wrapper library",
-    long_description=long_description,
-    url="https://github.com/andr1an/nic-api",
-    author="Sergey Andrianov",
-    author_email="info@andrian.ninja",
-    license="GPLv3",
-    packages=["nic_api"],
-    classifiers=[
-        "Development Status :: 4 - Beta",
-        "Topic :: Internet :: Name Service (DNS)",
-        "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
-        "Programming Language :: Python",
-        "Programming Language :: Python :: 2",
-        "Programming Language :: Python :: 2.7",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.4",
-        "Programming Language :: Python :: 3.5",
-        "Programming Language :: Python :: 3.6",
-        "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9",
-        "Programming Language :: Python :: 3.10",
-        "Programming Language :: Python :: 3.11",
-    ],
-    python_requires=">=2.7, <4.0",
-    install_requires=[
-        "requests>=2.4",
-        "requests-oauthlib>=1.1",
-    ],
-    project_urls={
-        "Bug Reports": "https://github.com/andr1an/nic-api/issues",
-        "Source": "https://github.com/andr1an/nic-api",
-    },
-)
diff --git a/nic_api/__init__.py b/src/nic_api/__init__.py
similarity index 77%
rename from nic_api/__init__.py
rename to src/nic_api/__init__.py
index 2c40320..7a32e7b 100644
--- a/nic_api/__init__.py
+++ b/src/nic_api/__init__.py
@@ -1,7 +1,10 @@
-"""NIC.RU (Ru-Center) DNS services manager."""
+"""NIC.RU (Ru-Center) DNS API library."""
 
-from __future__ import print_function
+
+from typing import List
+from typing import Union
 from xml.etree import ElementTree
+import importlib.metadata
 import logging
 
 from requests_oauthlib import OAuth2Session
@@ -16,6 +19,8 @@
     DnsApiException,
     ExpiredToken,
     InvalidRecord,
+    ServiceNotFound,
+    ZoneNotFound,
 )
 from nic_api.models import (
     parse_record,
@@ -38,10 +43,12 @@
 )
 
 
+__version__ = importlib.metadata.version("nic_api")
+
 logger = logging.getLogger(__name__)
 
 
-def is_sequence(arg):
+def _is_sequence(arg):
     """Returns if argument is list/tuple/etc. or not."""
     return (
         not hasattr(arg, "strip")
@@ -64,9 +71,9 @@ def pprint(record):
         "{bracket:>50}"
     )
     _format_srv = "{:45} {:6} {:6} {:6} {:6} {:6} {:45}"
-    _format_hinfo = "{:45} {:6} {:6} {:45} {:45}"
-    _format_naptr = "{:45} {:6} {:6} {:45} {:45} {:45} {:45} {:45} {}"
-    _format_rp = "{:45} {:6} {:6} {:45} {:45}"
+    _format_hinfo = '{:45} {:6} {:6} "{}" "{}"'
+    _format_naptr = '{:45} {:6} {:6} {:6} {:6} "{}" "{}" "{}" "{}"'
+    _format_rp = "{:45} {:6} {:6} {} {}"
 
     if isinstance(record, ARecord):
         print(
@@ -89,14 +96,17 @@ def pprint(record):
     elif isinstance(record, CNAMERecord):
         print(
             _format_default.format(
-                record.name, record.ttl, "CNAME", record.cname
+                record.name,
+                record.ttl if record.ttl is not None else "",
+                "CNAME",
+                record.cname,
             )
         )
     elif isinstance(record, MXRecord):
         print(
             _format_mx.format(
                 record.name,
-                record.ttl,
+                record.ttl if record.ttl is not None else "",
                 "MX",
                 record.preference,
                 record.exchange,
@@ -104,7 +114,12 @@ def pprint(record):
         )
     elif isinstance(record, TXTRecord):
         print(
-            _format_default.format(record.name, record.ttl, "TXT", record.txt)
+            _format_default.format(
+                record.name,
+                record.ttl if record.ttl is not None else "",
+                "TXT",
+                record.txt,
+            )
         )
     elif isinstance(record, NSRecord):
         print(_format_default.format(record.name, " ", "NS", record.ns))
@@ -126,7 +141,7 @@ def pprint(record):
         print(
             _format_srv.format(
                 record.name,
-                record.ttl,
+                record.ttl if record.ttl is not None else "",
                 "SRV",
                 record.priority,
                 record.weight,
@@ -136,38 +151,54 @@ def pprint(record):
         )
     elif isinstance(record, PTRRecord):
         print(
-            _format_default.format(record.name, record.ttl, "PTR", record.ptr)
+            _format_default.format(
+                record.name if record.name is not None else "",
+                record.ttl if record.ttl is not None else "",
+                "PTR",
+                record.ptr,
+            )
         )
     elif isinstance(record, DNAMERecord):
         print(
             _format_default.format(
-                record.name, record.ttl, "DNAME", record.dname
+                record.name,
+                record.ttl if record.ttl is not None else "",
+                "DNAME",
+                record.dname,
             )
         )
     elif isinstance(record, HINFORecord):
         print(
             _format_hinfo.format(
-                record.name, record.ttl, "HINFO", record.hardware, record.os
+                record.name,
+                record.ttl if record.ttl is not None else "",
+                "HINFO",
+                record.hardware,
+                record.os,
             )
         )
     elif isinstance(record, NAPTRRecord):
         print(
             _format_naptr.format(
                 record.name,
-                record.ttl,
+                record.ttl if record.ttl is not None else "",
                 "NAPTR",
                 record.order,
                 record.preference,
                 record.flags,
                 record.service,
-                record.regexp,
-                record.replacement,
+                record.regexp if record.regexp is not None else "",
+                record.replacement if record.replacement is not None else "",
             )
         )
     elif isinstance(record, RPRecord):
         print(
             _format_rp.format(
-                record.name, record.ttl, "RP", record.mbox, record.txt
+                record.name,
+                record.ttl if record.ttl is not None else "",
+                "RP",
+                record.mbox,
+                record.txt,
             )
         )
     else:
@@ -175,16 +206,16 @@ def pprint(record):
         print("Unknown record type: {}".format(type(record)))
 
 
-def raise_error(raw_xml):
+def raise_error(raw_xml: str):
     """Tries to parse API errors and raise proper exception."""
     try:
         root = ElementTree.fromstring(raw_xml)
         errors = root.findall("errors/error")
     except ElementTree.ParseError:
-        return None
+        return
 
     if len(errors) != 1:
-        return None
+        return
 
     error_code = int(errors[0].attrib.get("code", -1))
     error_text = errors[0].text
@@ -192,11 +223,13 @@ def raise_error(raw_xml):
         raise ExpiredToken(error_text)
     elif error_code == 4327:
         raise InvalidRecord(error_text)
-
-    return None
+    elif error_code == 4009:
+        raise ServiceNotFound(error_text)
+    elif error_code == 4028:
+        raise ZoneNotFound(error_text)
 
 
-def get_data(response):
+def get_data(response: requests.Response):
     """Gets <data> from XML response.
 
     Arguments:
@@ -205,13 +238,9 @@ def get_data(response):
     Returns:
         (xml.etree.ElementTree.Element) <data> tag of response.
     """
-    if not isinstance(response, requests.Response):
-        raise TypeError('"response" must be an instance of requests.Response')
-
-    # Processing API errors
     if response.status_code != requests.codes.ok:
-        if raise_error(response.text) is None:
-            raise DnsApiException(response.text)
+        raise_error(response.text)
+        raise DnsApiException(response.text)
 
     root = ElementTree.fromstring(response.text)
     datas = root.findall("data")
@@ -242,7 +271,8 @@ class DnsApi(object):
 
     For easier calling public methods, it is possible to set up a default
     NIC.RU service and a DNS zone via `DnsApi.default_service` and
-    `DnsApi.default_zone` attributes.
+    `DnsApi.default_zone` attributes. In case of using IDNs (non-ASCII domain
+    names), those attribute values should be encoded with Punycode.
     """
 
     base_url = "https://api.nic.ru"
@@ -290,7 +320,7 @@ def _token_updater(self, token):
         if self._token_updater_clb is not None:
             self._token_updater_clb(token)
 
-    def get_token(self, username, password):
+    def get_token(self, username, password) -> None:
         """Gets authorization token."""
         try:
             token = self._session.fetch_token(
@@ -305,7 +335,7 @@ def get_token(self, username, password):
             raise DnsApiException(str(err))
         self._token_updater(token)
 
-    def refresh_token(self, refresh_token):
+    def refresh_token(self, refresh_token) -> None:
         """Refreshes authorization token."""
         try:
             token = self._session.refresh_token(
@@ -338,7 +368,7 @@ def _delete(self, url):
         """Wraps requests.delete()"""
         return self._session.delete(self._url_for(url))
 
-    def services(self):
+    def services(self) -> List[NICService]:
         """Get services available for management.
 
         Returns:
@@ -348,7 +378,7 @@ def services(self):
         data = get_data(response)
         return [NICService.from_xml(service) for service in data]
 
-    def zones(self, service=None):
+    def zones(self, service=None) -> List[NICZone]:
         """Get zones in service.
 
         Returns:
@@ -356,13 +386,13 @@ def zones(self, service=None):
         """
         service = self.default_service if service is None else service
         if service is None:
-            response = self._get("zones".format(service))
+            response = self._get("zones")
         else:
             response = self._get("services/{}/zones".format(service))
         data = get_data(response)
         return [NICZone.from_xml(zone) for zone in data]
 
-    def zonefile(self, service=None, zone=None):
+    def zonefile(self, service=None, zone=None) -> str:
         """Get zone file for single zone.
 
         Returns:
@@ -373,7 +403,7 @@ def zonefile(self, service=None, zone=None):
         response = self._get("services/{}/zones/{}".format(service, zone))
         return response.text
 
-    def records(self, service=None, zone=None):
+    def records(self, service=None, zone=None) -> List[DNSRecord]:
         """Get all records for single zone.
 
         Returns:
@@ -389,7 +419,12 @@ def records(self, service=None, zone=None):
         assert _zone.attrib["name"] == zone
         return [parse_record(rr) for rr in _zone.findall("rr")]
 
-    def add_record(self, records, service=None, zone=None):
+    def add_record(
+        self,
+        records: Union[DNSRecord, List[DNSRecord]],
+        service=None,
+        zone=None,
+    ) -> List[DNSRecord]:
         """Adds records.
 
         Returns:
@@ -397,7 +432,7 @@ def add_record(self, records, service=None, zone=None):
         """
         service = self.default_service if service is None else service
         zone = self.default_zone if zone is None else zone
-        if not is_sequence(records):
+        if not _is_sequence(records):
             _records = [records]
         else:
             _records = list(records)
@@ -405,8 +440,6 @@ def add_record(self, records, service=None, zone=None):
         rr_list = []  # for XML representations
 
         for record in _records:
-            if not isinstance(record, DNSRecord):
-                raise TypeError("{} is not a valid DNS record".format(record))
             record_xml = record.to_xml()
             rr_list.append(record_xml)
             logger.debug(
@@ -428,10 +461,10 @@ def add_record(self, records, service=None, zone=None):
         )
 
         if response.status_code != requests.codes.ok:
-            if raise_error(response.text) is None:
-                raise DnsApiException(
-                    "Failed to add new records:\n{}".format(response.text)
-                )
+            raise_error(response.text)
+            raise DnsApiException(
+                "Failed to add new records:\n{}".format(response.text)
+            )
 
         logger.info("Successfully added %s records", len(rr_list))
         data = get_data(response)
@@ -439,10 +472,8 @@ def add_record(self, records, service=None, zone=None):
         assert _zone.attrib["name"] == zone
         return [parse_record(rr) for rr in _zone.findall("rr")]
 
-    def delete_record(self, record_id, service=None, zone=None):
+    def delete_record(self, record_id: int, service=None, zone=None) -> None:
         """Deletes record by id."""
-        if not isinstance(record_id, int):
-            raise TypeError('"record_id" is not a valid int')
         service = self.default_service if service is None else service
         zone = self.default_zone if zone is None else zone
 
@@ -458,13 +489,14 @@ def delete_record(self, record_id, service=None, zone=None):
         )
 
         if response.status_code != requests.codes.ok:
-            if raise_error(response.text) is None:
-                raise DnsApiException(
-                    "Failed to delete record: {}".format(response.text)
-                )
+            raise_error(response.text)
+            raise DnsApiException(
+                "Failed to delete record:\n{}".format(response.text)
+            )
+
         logger.info("Record #%s deleted", record_id)
 
-    def commit(self, service=None, zone=None):
+    def commit(self, service=None, zone=None) -> None:
         """Commits changes in zone."""
         service = self.default_service if service is None else service
         zone = self.default_zone if zone is None else zone
@@ -472,13 +504,13 @@ def commit(self, service=None, zone=None):
             "services/{}/zones/{}/commit".format(service, zone)
         )
         if response.status_code != requests.codes.ok:
-            if raise_error(response.text) is None:
-                raise DnsApiException(
-                    "Failed to commit changes:\n{}".format(response.text)
-                )
+            raise_error(response.text)
+            raise DnsApiException(
+                "Failed to commit changes:\n{}".format(response.text)
+            )
         logger.info("Changes committed")
 
-    def rollback(self, service=None, zone=None):
+    def rollback(self, service=None, zone=None) -> None:
         """Rolls back changes in zone."""
         service = self.default_service if service is None else service
         zone = self.default_zone if zone is None else zone
@@ -486,8 +518,8 @@ def rollback(self, service=None, zone=None):
             "services/{}/zones/{}/rollback".format(service, zone)
         )
         if response.status_code != requests.codes.ok:
-            if raise_error(response.text) is None:
-                raise DnsApiException(
-                    "Failed to rollback changes:\n{}".format(response.text)
-                )
+            raise_error(response.text)
+            raise DnsApiException(
+                "Failed to rollback changes:\n{}".format(response.text)
+            )
         logger.info("Changes are rolled back")
diff --git a/nic_api/exceptions.py b/src/nic_api/exceptions.py
similarity index 59%
rename from nic_api/exceptions.py
rename to src/nic_api/exceptions.py
index 5a87c2d..f5e667d 100644
--- a/nic_api/exceptions.py
+++ b/src/nic_api/exceptions.py
@@ -11,3 +11,11 @@ class ExpiredToken(DnsApiException):
 
 class InvalidRecord(DnsApiException):
     """Raised when invalid record data was passed to the API."""
+
+
+class ServiceNotFound(DnsApiException):
+    """Raised when specified service was not found."""
+
+
+class ZoneNotFound(DnsApiException):
+    """Raised when specified DNS zone was not found on the service."""
diff --git a/nic_api/models.py b/src/nic_api/models.py
similarity index 84%
rename from nic_api/models.py
rename to src/nic_api/models.py
index ae74a18..5c92f91 100644
--- a/nic_api/models.py
+++ b/src/nic_api/models.py
@@ -1,8 +1,7 @@
 """nic_api - classes for entities returned by API."""
 
-from xml.etree import ElementTree
 
-from nic_api.compat import XML_ENCODING
+from xml.etree import ElementTree
 
 
 def _strtobool(string):
@@ -10,7 +9,7 @@ def _strtobool(string):
     return {"true": True, "false": False}[string]
 
 
-def parse_record(rr):
+def parse_record(rr: ElementTree.Element):
     """Parses record XML representation to one of DNSRecord subclasses.
 
     Reads <rr> tag, gets record type and passes <rr> internals to the specific
@@ -23,9 +22,6 @@ def parse_record(rr):
         one of SOARecord, NSRecord, ARecord, AAAARecord, CNAMERecord, MXRecord,
         TXTRecord.
     """
-    if not isinstance(rr, ElementTree.Element):
-        raise TypeError('"rr" must be an instance of ElementTree.Element')
-
     record_classes = {
         "SOA": SOARecord,
         "NS": NSRecord,
@@ -88,15 +84,10 @@ def __repr__(self):
         return repr(vars(self))
 
     @classmethod
-    def from_xml(cls, service):
+    def from_xml(cls, service: ElementTree.Element):
         """Alternative constructor - creates an instance of NICService from
         its XML representation.
         """
-        if not isinstance(service, ElementTree.Element):
-            raise TypeError(
-                '"service" must be an instance of ElementTree.Element'
-            )
-
         kwargs = {k.replace("-", "_"): v for k, v in service.attrib.items()}
         kwargs["enable"] = _strtobool(kwargs["enable"])
         kwargs["has_primary"] = _strtobool(kwargs["has_primary"])
@@ -115,8 +106,8 @@ def __init__(
         self,
         admin,
         enable,
-        has_changes,
-        has_primary,
+        has_changes: bool,
+        has_primary: bool,
         id_,
         idn_name,
         name,
@@ -141,13 +132,10 @@ def to_xml(self):
         raise NotImplementedError("Not implemented")
 
     @classmethod
-    def from_xml(cls, zone):
+    def from_xml(cls, zone: ElementTree.Element):
         """Alternative constructor - creates an instance of NICZone from
         its XML representation.
         """
-        if not isinstance(zone, ElementTree.Element):
-            raise TypeError('"zone" must be an instance of ElementTree.Element')
-
         kwargs = {k.replace("-", "_"): v for k, v in zone.attrib.items()}
 
         kwargs["id_"] = kwargs["id"]
@@ -183,8 +171,15 @@ def __init__(self, id_=None, name="", idn_name=None):
             self.id = int(id_)
         if self.id == 0:
             raise ValueError("Invalid record ID")
+        if name is not None and not name.isascii():
+            raise ValueError("Name should be an ASCII string")
         self.name = name
-        self.idn_name = idn_name if idn_name else name
+        if idn_name is not None:
+            self.idn_name = idn_name
+        elif name is not None:
+            self.idn_name = name.encode().decode("idna")
+        else:
+            self.idn_name = name
 
     def __repr__(self):
         return repr(vars(self))
@@ -237,15 +232,13 @@ def to_xml(self):
         ElementTree.SubElement(_soa, "retry").text = str(self.retry)
         ElementTree.SubElement(_soa, "expire").text = str(self.expire)
         ElementTree.SubElement(_soa, "minimum").text = str(self.minimum)
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of SOARecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "SOA":
             raise ValueError("Record is not an SOA record")
 
@@ -270,14 +263,8 @@ def from_xml(cls, rr):
 class NSRecord(DNSRecord):
     """Model of NS record."""
 
-    ttl = None
-
-    def __init__(self, ns, ttl=None, **kwargs):
+    def __init__(self, ns, **kwargs):
         super(NSRecord, self).__init__(**kwargs)
-        if ttl is not None:
-            self.ttl = int(ttl)
-            if self.ttl == 0:
-                raise ValueError("Invalid TTL")
         self.ns = ns
 
     @property
@@ -289,15 +276,13 @@ def to_xml(self):
         root = self.as_etree
         _ns = ElementTree.SubElement(root, "ns")
         ElementTree.SubElement(_ns, "name").text = self.ns
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of NSRecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "NS":
             raise ValueError("Record is not an NS record")
 
@@ -329,15 +314,13 @@ def to_xml(self):
         """Returns an XML representation of record object."""
         root = self.as_etree
         ElementTree.SubElement(root, "a").text = self.a
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of ARecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "A":
             raise ValueError("Record is not an A record")
 
@@ -371,15 +354,13 @@ def to_xml(self):
         """Returns an XML representation of record object."""
         root = self.as_etree
         ElementTree.SubElement(root, "aaaa").text = self.aaaa
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of AAAARecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "AAAA":
             raise ValueError("Record is not an AAAA record")
 
@@ -414,15 +395,13 @@ def to_xml(self):
         root = self.as_etree
         _cname = ElementTree.SubElement(root, "cname")
         ElementTree.SubElement(_cname, "name").text = self.cname
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of CNAMERecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "CNAME":
             raise ValueError("Record is not a CNAME record")
 
@@ -460,15 +439,13 @@ def to_xml(self):
         ElementTree.SubElement(_mx, "preference").text = str(self.preference)
         _exchange = ElementTree.SubElement(_mx, "exchange")
         ElementTree.SubElement(_exchange, "name").text = str(self.exchange)
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of MXRecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "MX":
             raise ValueError("Record is not an MX record")
 
@@ -511,15 +488,13 @@ def to_xml(self):
         root = self.as_etree
         _txt = ElementTree.SubElement(root, "txt")
         ElementTree.SubElement(_txt, "string").text = self.txt
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of TXTRecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "TXT":
             raise ValueError("Record is not a TXT record")
 
@@ -563,15 +538,13 @@ def to_xml(self):
         ElementTree.SubElement(_srv, "port").text = str(self.port)
         _target = ElementTree.SubElement(_srv, "target")
         ElementTree.SubElement(_target, "name").text = str(self.target)
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of SRVRecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "SRV":
             raise ValueError("Record is not an SRV record")
 
@@ -618,15 +591,13 @@ def to_xml(self):
         root = self.as_etree
         _ptr = ElementTree.SubElement(root, "ptr")
         ElementTree.SubElement(_ptr, "name").text = self.ptr
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of PTRRecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "PTR":
             raise ValueError("Record is not a PTR record")
 
@@ -661,15 +632,13 @@ def to_xml(self):
         root = self.as_etree
         _dname = ElementTree.SubElement(root, "dname")
         ElementTree.SubElement(_dname, "name").text = self.dname
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of DNAMERecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "DNAME":
             raise ValueError("Record is not a DNAME record")
 
@@ -686,16 +655,13 @@ class HINFORecord(DNSRecord):
     """Model of HINFO record."""
 
     ttl = None
-    hardware = None
-    os = None
 
-    def __init__(self, hinfo, ttl=None, hardware=None, os=None, **kwargs):
+    def __init__(self, hardware, os, ttl=None, **kwargs):
         super(HINFORecord, self).__init__(**kwargs)
         if ttl is not None:
             self.ttl = int(ttl)
             if self.ttl == 0:
                 raise ValueError("Invalid TTL")
-        self.hinfo = hinfo
         self.hardware = hardware
         self.os = os
 
@@ -709,15 +675,13 @@ def to_xml(self):
         _hinfo = ElementTree.SubElement(root, "hinfo")
         ElementTree.SubElement(_hinfo, "hardware").text = self.hardware
         ElementTree.SubElement(_hinfo, "os").text = self.os
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of HINFORecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "HINFO":
             raise ValueError("Record is not an HINFO record")
 
@@ -742,24 +706,17 @@ class NAPTRRecord(DNSRecord):
     """Model of NAPTR record."""
 
     ttl = None
-    order = None
-    preference = None
-    flags = None
-    service = None
-    regexp = None
-    replacement = None
 
     def __init__(
         self,
-        naptr,
+        order,
+        preference,
+        flags,
+        service,
+        regexp="",
+        replacement="",
         ttl=None,
-        order=None,
-        preference=None,
-        flags=None,
-        service=None,
-        regexp=None,
-        replacement=None,
-        **kwargs
+        **kwargs,
     ):
         super(NAPTRRecord, self).__init__(**kwargs)
         if ttl is not None:
@@ -768,7 +725,6 @@ def __init__(
                 raise ValueError("Invalid TTL")
         self.order = int(order)
         self.preference = int(preference)
-        self.naptr = naptr
         self.flags = flags
         self.service = service
         self.regexp = regexp
@@ -789,15 +745,13 @@ def to_xml(self):
         ElementTree.SubElement(_naptr, "regexp").text = self.regexp
         _replacement = ElementTree.SubElement(_naptr, "replacement")
         ElementTree.SubElement(_replacement, "name").text = self.replacement
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of NAPTRRecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "NAPTR":
             raise ValueError("Record is not an NAPTR record")
 
@@ -809,8 +763,8 @@ def from_xml(cls, rr):
         order = rr.find("naptr/order").text
         preference = rr.find("naptr/preference").text
         flags = rr.find("naptr/flags").text
-        services = rr.find("naptr/service").text
-        expression = rr.find("naptr/regexp").text
+        service = rr.find("naptr/service").text
+        regexp = rr.find("naptr/regexp").text
         replacement = rr.find("naptr/replacement/name").text
         return cls(
             id_=id_,
@@ -820,8 +774,8 @@ def from_xml(cls, rr):
             order=order,
             preference=preference,
             flags=flags,
-            services=services,
-            expression=expression,
+            service=service,
+            regexp=regexp,
             replacement=replacement,
         )
 
@@ -830,16 +784,13 @@ class RPRecord(DNSRecord):
     """Model of RP record."""
 
     ttl = None
-    mbox = None
-    txt = None
 
-    def __init__(self, rp, ttl=None, mbox=None, txt=None, **kwargs):
+    def __init__(self, mbox, txt, ttl=None, **kwargs):
         super(RPRecord, self).__init__(**kwargs)
         if ttl is not None:
             self.ttl = int(ttl)
             if self.ttl == 0:
                 raise ValueError("Invalid TTL")
-        self.rp = rp
         self.mbox = mbox
         self.txt = txt
 
@@ -855,15 +806,13 @@ def to_xml(self):
         ElementTree.SubElement(_mbox, "name").text = self.mbox
         _txt = ElementTree.SubElement(_rp, "txt-dname")
         ElementTree.SubElement(_txt, "name").text = self.txt
-        return ElementTree.tostring(root, encoding=XML_ENCODING)
+        return ElementTree.tostring(root, encoding="unicode")
 
     @classmethod
-    def from_xml(cls, rr):
+    def from_xml(cls, rr: ElementTree.Element):
         """Alternative constructor - creates an instance of RPRecord from
         its XML representation.
         """
-        if not isinstance(rr, ElementTree.Element):
-            raise TypeError('"rr" must be an instance of ElementTree.Element')
         if rr.find("type").text != "RP":
             raise ValueError("Record is not an HINFO record")
 


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
new file mode 100644
index 0000000..2072e81
--- /dev/null
+++ b/.github/workflows/tests.yaml
@@ -0,0 +1,26 @@
+name: Tests
+on: [push]
+jobs:
+  tests:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version:
+          - "3.8"
+          - "3.9"
+          - "3.10"
+          - "3.11"
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install -U pip
+          python -m pip install '.[dev]'
+      - name: Linter check
+        run: black --check --diff .
+      - name: Run pytest
+        run: pytest
diff --git a/tests/test_records.py b/tests/test_records.py
index a7a78ee..8bb3c13 100644
--- a/tests/test_records.py
+++ b/tests/test_records.py
@@ -1,75 +1,205 @@
-"""Simulates `DnsApi.add_record()` beahvior to test Python 2.7 and 3.X `str`
-compatibility.
-"""
+from xml.etree import ElementTree
+
+from nic_api.models import (
+    parse_record,
+    SOARecord,
+    NSRecord,
+    ARecord,
+    AAAARecord,
+    CNAMERecord,
+    MXRecord,
+    TXTRecord,
+    SRVRecord,
+    PTRRecord,
+    DNAMERecord,
+    HINFORecord,
+    NAPTRRecord,
+    RPRecord,
+)
+
+
+def _parse_record_nonstrict(xml_string: str) -> ElementTree.Element:
+    rr = ElementTree.fromstring(xml_string)
+    if not rr.find("idn-name"):
+        _name = rr.find("name").text
+        if _name:
+            _name = _name.encode().decode("idna")
+        ElementTree.SubElement(rr, "idn-name").text = _name
+    return parse_record(rr)
+
+
+def test_export_and_parse_soa():
+    record = SOARecord(
+        serial=1001,
+        refresh=86400,
+        retry=7200,
+        expire=4000000,
+        minimum=14400,
+        mname={"name": "ns.nic-api-test.com."},
+        rname={"name": "admin.nic-api-test.com."},
+    )
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, SOARecord)
+    assert record_parsed.serial == 1001
+    assert record_parsed.refresh == 86400
+    assert record_parsed.retry == 7200
+    assert record_parsed.expire == 4000000
+    assert record_parsed.minimum == 14400
+    assert record_parsed.mname.name == "ns.nic-api-test.com."
+    assert record_parsed.rname.name == "admin.nic-api-test.com."
+
+
+def test_export_and_parse_ns():
+    record = NSRecord(ns="ns.nic-api-test.com.")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, NSRecord)
+    assert record_parsed.ns == "ns.nic-api-test.com."
 
-import random
-import textwrap
 
-from nic_api.models import ARecord, CNAMERecord, TXTRecord
+def test_export_and_parse_a():
+    record = ARecord(a="192.168.0.1")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, ARecord)
+    assert record_parsed.a == "192.168.0.1"
 
 
-def test_a_root():
-    record = ARecord(a="255.255.255.255")
+def test_export_and_parse_a_idna():
+    record = ARecord(a="192.168.0.2", name="тест".encode("idna").decode())
     record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
     assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, ARecord)
+    assert record_parsed.a == "192.168.0.2"
+    assert record_parsed.name == "xn--e1aybc"
+    assert record_parsed.idn_name == "тест"
 
 
-def test_a_to_xml():
-    rr_list = []
+def test_export_and_parse_aaaa():
+    record = AAAARecord(aaaa="fe80:cafe:cafe:cafe:cafe:cafe")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, AAAARecord)
+    assert record_parsed.aaaa == "fe80:cafe:cafe:cafe:cafe:cafe"
 
-    for _ in range(3):
-        _name = "testrec{:.0f}".format(random.random() * 1000.0)
-        record = ARecord(name=_name, a="255.255.255.255")
-        record_xml = record.to_xml()
-        rr_list.append(record_xml)
 
-    _xml = textwrap.dedent(
-        """\
-        <?xml version="1.0" encoding="UTF-8" ?>
-        <request><rr-list>
-        {}
-        </rr-list></request>"""
-    ).format("\n".join(rr_list))
+def test_export_and_parse_cname():
+    record = CNAMERecord(cname="www")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, CNAMERecord)
+    assert record_parsed.cname == "www"
 
-    assert _xml
 
+def test_export_and_parse_mx():
+    record = MXRecord(preference=10, exchange="mail")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, MXRecord)
+    assert record_parsed.preference == 10
+    assert record_parsed.exchange == "mail"
 
-def test_cname_to_xml():
-    rr_list = []
 
-    for _ in range(3):
-        _name = "testrec{:.0f}".format(random.random() * 1000.0)
-        record = CNAMERecord(name=_name, cname="www")
-        record_xml = record.to_xml()
-        rr_list.append(record_xml)
+def test_export_and_parse_txt():
+    record = TXTRecord(txt="hello world")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, TXTRecord)
+    assert record_parsed.txt == "hello world"
+
+
+def test_export_and_parse_srv():
+    record = SRVRecord(
+        priority=0,
+        weight=5,
+        port=9999,
+        target="example.nic-api-test.com.",
+    )
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, SRVRecord)
+    assert record_parsed.priority == 0
+    assert record_parsed.weight == 5
+    assert record_parsed.port == 9999
+    assert record_parsed.target == "example.nic-api-test.com."
 
-    _xml = textwrap.dedent(
-        """\
-        <?xml version="1.0" encoding="UTF-8" ?>
-        <request><rr-list>
-        {}
-        </rr-list></request>"""
-    ).format("\n".join(rr_list))
 
-    assert _xml
+def test_export_and_parse_ptr():
+    record = PTRRecord(ptr="1.0.168.192.in-addr.arpa.")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, PTRRecord)
+    assert record_parsed.ptr == "1.0.168.192.in-addr.arpa."
 
 
-def test_txt_to_xml():
-    rr_list = []
+def test_export_and_parse_dname():
+    record = DNAMERecord(dname="nic-api-test-2.com.")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, DNAMERecord)
+    assert record_parsed.dname == "nic-api-test-2.com."
 
-    for _ in range(3):
-        _name = "testrec{:.0f}".format(random.random() * 1000.0)
-        _txt = "{:.0f}".format(random.random() * 1000000000.0)
-        record = TXTRecord(name=_name, txt=_txt)
-        record_xml = record.to_xml()
-        rr_list.append(record_xml)
 
-    _xml = textwrap.dedent(
-        """\
-        <?xml version="1.0" encoding="UTF-8" ?>
-        <request><rr-list>
-        {}
-        </rr-list></request>"""
-    ).format("\n".join(rr_list))
+def test_export_and_parse_hinfo():
+    record = HINFORecord(hardware="IBM-PC/XT", os="OS/2")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, HINFORecord)
+    assert record_parsed.hardware == "IBM-PC/XT"
+    assert record_parsed.os == "OS/2"
+
+
+def test_export_and_parse_naptr():
+    record = NAPTRRecord(
+        order=1,
+        preference=100,
+        flags="S",
+        service="sip+D2U",
+        replacement="_sip._udp.nic-api-test.com.",
+    )
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, NAPTRRecord)
+    assert record_parsed.order == 1
+    assert record_parsed.preference == 100
+    assert record_parsed.flags == "S"
+    assert record_parsed.service == "sip+D2U"
+    assert record_parsed.replacement == "_sip._udp.nic-api-test.com."
 
-    assert _xml
+
+def test_export_and_parse_rp():
+    record = RPRecord(mbox="info.andrian.ninja.", txt=".")
+    record_xml = record.to_xml()
+    assert isinstance(record_xml, str)
+    assert record_xml
+    record_parsed = _parse_record_nonstrict(record_xml)
+    assert isinstance(record_parsed, RPRecord)
+    assert record_parsed.mbox == "info.andrian.ninja."
+    assert record_parsed.txt == "."