Repository: AnalogJ/lexicon

Issue:
change transip provider to use transip-api
I noticed the transip provider was gone, and that there was a [new project](https://github.com/benkonrath/transip-api) providing a transip api.

I'm not sure how to specify the dependency from github, might be useful to put transip-api on pypi. Otherwise, command line usage seems to work and the tests pass. I haven't tried using it with dehydrated yet.


Discussion:

Resolving patch:
diff --git a/lexicon/providers/transip.py b/lexicon/providers/transip.py
new file mode 100644
--- /dev/null
+++ b/lexicon/providers/transip.py
@@ -0,0 +1,169 @@
+from __future__ import print_function
+from __future__ import absolute_import
+from .base import Provider as BaseProvider
+try:
+    from transip.service.dns import DnsEntry
+    from transip.service.domain import DomainService
+except ImportError:
+    pass
+
+
+
+def ProviderParser(subparser):
+    subparser.add_argument("--auth-username", help="specify username used to authenticate")
+    subparser.add_argument("--auth-api-key", help="specify API private key to authenticate")
+
+
+class Provider(BaseProvider):
+    def __init__(self, options, provider_options={}):
+        super(Provider, self).__init__(options)
+        self.options.update(provider_options)
+        self.provider_name = 'transip'
+        self.domain_id = None
+
+        username = self.options.get('auth_username')
+        key_file = self.options.get('auth_api_key')
+
+        if not username or not key_file:
+            raise Exception("No username and/or keyfile was specified")
+
+        self.client = DomainService(
+            login=username,
+            private_key_file=key_file
+        )
+
+    # Authenticate against provider,
+    # Make any requests required to get the domain's id for this provider, so it can be used in subsequent calls.
+    # Should throw an error if authentication fails for any reason, of if the domain does not exist.
+    def authenticate(self):
+        ## This request will fail when the domain does not exist,
+        ## allowing us to check for existence
+        domain = self.options.get('domain')
+        try:
+            self.client.get_info(domain)
+        except:
+            raise
+            raise Exception("Could not retrieve information about {0}, "
+                                "is this domain yours?".format(domain))
+        self.domain_id = domain
+
+    # Create record. If record already exists with the same content, do nothing'
+    def create_record(self, type, name, content):
+        records = self.client.get_info(self.options.get('domain')).dnsEntries
+        if self._filter_records(records, type, name, content):
+            # Nothing to do, record already exists
+            print('create_record: already exists')
+            return True
+
+        records.append(DnsEntry(**{
+            "name": self._relative_name(name),
+            "record_type": type,
+            "content": self._bind_format_target(type, content),
+            "expire": self.options.get('ttl') or 86400
+        }))
+
+        self.client.set_dns_entries(self.options.get('domain'), records)
+        status = len(self.list_records(type, name, content, show_output=False)) >= 1
+        print("create_record: {0}".format(status))
+        return status
+
+    # List all records. Return an empty list if no records found
+    # type, name and content are used to filter records.
+    # If possible filter during the query, otherwise filter after response is received.
+    def list_records(self, type=None, name=None, content=None, show_output=True):
+        all_records = self._convert_records(self.client.get_info(self.options.get('domain')).dnsEntries)
+        records = self._filter_records(
+            records=all_records,
+            type=type,
+            name=name,
+            content=content
+        )
+
+        if show_output:
+            print('list_records: {0}'.format(records))
+        return records
+
+    # Update a record. Identifier must be specified.
+    def update_record(self, identifier=None, type=None, name=None, content=None):
+        if not (type or name or content):
+            raise Exception("At least one of type, name or content must be specified.")
+
+        all_records = self.list_records(show_output=False)
+        filtered_records = self._filter_records(all_records, type, name)
+
+        for record in filtered_records:
+            all_records.remove(record)
+        all_records.append({
+            "name": name,
+            "type": type,
+            "content": self._bind_format_target(type, content),
+            "ttl": self.options.get('ttl') or 86400
+        })
+
+
+        self.client.set_dns_entries(self.options.get('domain'), self._convert_records_back(all_records))
+        status = len(self.list_records(type, name, content, show_output=False)) >= 1
+        print("update_record: {0}".format(status))
+        return status
+
+    # Delete an existing record.
+    # If record does not exist, do nothing.
+    # If an identifier is specified, use it, otherwise do a lookup using type, name and content.
+    def delete_record(self, identifier=None, type=None, name=None, content=None):
+        if not (type or name or content):
+            raise Exception("At least one of type, name or content must be specified.")
+
+        all_records = self.list_records(show_output=False)
+        filtered_records = self._filter_records(all_records, type, name, content)
+
+        for record in filtered_records:
+            all_records.remove(record)
+
+        self.client.set_dns_entries(self.options.get('domain'), self._convert_records_back(all_records))
+        status = len(self.list_records(type, name, content, show_output=False)) == 0
+        print("delete_record: {0}".format(status))
+        return status
+
+    def _full_name(self, record_name):
+        if record_name == "@":
+            record_name = self.options['domain']
+        return super(Provider, self)._full_name(record_name)
+
+    def _relative_name(self, record_name):
+        name = super(Provider, self)._relative_name(record_name)
+        if not name:
+            name = "@"
+        return name
+
+    def _bind_format_target(self, type, target):
+        if type == "CNAME" and not target.endswith("."):
+            target += "."
+        return target
+
+    # Convert the objects from transip to dicts, for easier processing
+    def _convert_records(self, records):
+        _records = []
+        for record in records:
+            _records.append({
+                "id": "{0}-{1}".format(self._full_name(record.name), record.type),
+                "name": self._full_name(record.name),
+                "type": record.type,
+                "content": record.content,
+                "ttl": record.expire
+            })
+        return _records
+
+    def _to_dns_entry(self, _entry):
+        return DnsEntry(self._relative_name(_entry['name']), _entry['ttl'], _entry['type'], _entry['content'])
+    def _convert_records_back(self, _records):
+        return [self._to_dns_entry(record) for record in _records]
+
+    # Filter a list of records based on criteria
+    def _filter_records(self, records, type=None, name=None, content=None):
+        _records = []
+        for record in records:
+            if (not type or record['type'] == type) and \
+               (not name or record['name'] == self._full_name(name)) and \
+               (not content or record['content'] == content):
+                _records.append(record)
+        return _records
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -46,18 +46,12 @@
     author='Jason Kulatunga',
     author_email='jason@thesparktree.com',
 
-    # Choose your license
     license='MIT',
 
     # See https://pypi.python.org/pypi?%3Aaction=list_classifiers
     classifiers=[
-        # How mature is this project? Common values are
-        #   3 - Alpha
-        #   4 - Beta
-        #   5 - Production/Stable
         'Development Status :: 5 - Production/Stable',
 
-        # Indicate who your project is intended for
         'Intended Audience :: Developers',
         'Intended Audience :: System Administrators',
         'Topic :: Software Development :: Libraries :: Python Modules',
@@ -65,7 +59,6 @@
         'Topic :: System :: Systems Administration',
         'Topic :: Utilities',
 
-        # Pick your license as you wish (should match "license" above)
         'License :: OSI Approved :: MIT License',
 
         # Specify the Python versions you support here. In particular, ensure
@@ -78,32 +71,24 @@
         'Programming Language :: Python :: 3.5',
     ],
 
-    # What does your project relate to?
     keywords='dns lexicon dns-lexicon dehydrated letsencrypt ' + ' '.join(providers),
 
-    # You can just specify the packages manually here if your project is
-    # simple. Or you can use find_packages().
     packages=find_packages(exclude=['contrib', 'docs', 'tests']),
 
-    # Alternatively, if you want to distribute just a my_module.py, uncomment
-    # this:
-    #   py_modules=["my_module"],
-
     # List run-time dependencies here.  These will be installed by pip when
     # your project is installed. For an analysis of "install_requires" vs pip's
     # requirements files see:
     # https://packaging.python.org/en/latest/requirements.html
     install_requires=['requests', 'tldextract', 'future'],
 
+    # Each dependency group in extras_require should match a provider name
+    # When adding a new depenency group here, please ensure that it has been
+    # added to optional-requirements.txt as well.
     extras_require={
-        'route53': ['boto3']
+        'route53': ['boto3'],
+        'transip': ['transip==0.1.0-dev']
     },
-
-    # Although 'package_data' is the preferred approach, in some case you may
-    # need to place data files outside of your packages. See:
-    # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # noqa
-    # In this case, 'data_file' will be installed into '<sys.prefix>/my_data'
-    # data_files=[('my_data', ['data/data_file'])],
+    dependency_links = ['git+https://github.com/benkonrath/transip-api.git#egg=transip-0.1.0-dev'],
 
     # To provide executable scripts, use entry points in preference to the
     # "scripts" keyword. Entry points provide cross-platform support and allow
@@ -113,6 +98,5 @@
             'lexicon=lexicon.__main__:main',
         ],
     },
-
     test_suite='tests'
 )


Test patch:
diff --git a/tests/providers/test_transip.py b/tests/providers/test_transip.py
new file mode 100644
--- /dev/null
+++ b/tests/providers/test_transip.py
@@ -0,0 +1,98 @@
+# Test for one implementation of the interface
+from lexicon.providers.transip import Provider
+from integration_tests import IntegrationTests, provider_vcr
+from unittest import TestCase
+import pytest
+from tempfile import mkstemp
+import os
+
+FAKE_KEY = b"""
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAxV08IlJRwNq9WyyGO2xRyT0F6XIBD2R5CrwJoP7gIHVU/Mhk
+KeK8//+MbUZtKFoeJi9lI8Cbkqe7GVk9yab6R2/vVzV21XRh+57R79nEh+QTf/vZ
+dg+DjUn62U4lcgoVp3sHddIi/Zi58xz2a2lGGIdolsv1x0/PmAQPULt721IG/osp
+RBjTtaZ8niXrOTfjH814i8kgXu74CCGu0X6kJBIezMA2wqY1ZKZYRMpfrxkEZe0t
+45pEM1CmSTCqyDMpwYou9wJaDHn0ts1KvKkKBfmO4B0nqfW9Sv9rkmpBCLTtMobj
+dQ8EwWv1L1g9uddkPALgRODEpR4fq7PTmq2VEQIDAQABAoIBAFf4wwEZaE9qMNUe
+94YtNhdZF/WCV26g/kMGpdQZR5WwNv2l5N+2rT/+jH140tcVtDKZFZ/mDnJESWV3
+Hc9wmkaVYj2hGyLyCWq61CDxFGTuCLMXc0roh17HBwUtjAtU62oHsL+XtvkKxnfT
+BRPDjPcKBFiS+S6qKII97QWzS/XpxL47VpXcYboVunzUncIKghC93LdvPp3ukh6x
+HIarqyctqkksLJtLgH5ffuABCJLChetpOIfcfspjtMoji43CXXd7Y3rGWy3EzSHA
+s4mNb4K6r8MOlJj3HiTn9bEgL2V2q3OHSYHYXexir67vkQeN+NsC80G0uODt6Uuo
+Cd1RobECgYEA+O+nZYRc22jI8oqRoQeCx6cTWJoaf4OYDXcaerRMIiE7yigHNgmX
+LGs9RYTVrWXzjM5KHVvPvavpm/zIBoa5fA7uqdH9BjuZVLm1COXzKxF5hevZuAxr
+zGQWDbdvzdsihPBvwlf0dKScA/WIRW0KCqUmC6IlS/An4Y0nI05P+KsCgYEAyvby
+cfUPgeanBnYE3GGou3cLiurzvK3vHuQl6vVE3DcheUj/5tKTwG5Q3/7y51MKHnfH
+xEc/X2IePXYVy0JwpC6NHzkyJPuJ1zYlkQGSs81TUbYOk9SKi3SL9bM+3vRzYFoL
+GMLJuvEqIscxLNqR0xQB5eBkg8T+AVJiA7cTITMCgYEAn5/ND2OYx3ihoiUIzOEs
+EyonVaE7bJjNX5UH/bavOxNka3TPau8raOg7GeDbw5ykV53QGJNO2qjp24R0Hvs0
+5UAN+gcU4HJHF/UdCN+q1esWqbFaopIUbbOgEJuXrcDembAzecM8la8X+9Ht19bb
+oYfUpZELqW4NpKwGdLU6wpECgYAfn3hI3xjKcYiGji7Vs3WZt8OZol/VfvgpxPxP
+bmWLNh/GCOSuLxMMQWPicpOgDSUfeCQs5bjvAJebleFxaOmp+wLL4Zp5fqOMX4hc
+3nTgBNa9fXMp/0ySy9besk3SaR3s3jqqYfcSZG7fOk/kIC3mSFC/Y0Xl7fRxekeB
+Mq4NVwKBgQDQ+3+cgZph5geq0PUuKMvMECDuCEnG8rrr4jTCe+sRP34y1IaxJ2w6
+S6p+kvTBePSqV2wWZCls6p7mhGEto+8H9b4pWdmSqccn0vFu4kekm/OU4+IxqzWQ
+KPeh76yhdzsFwzh+0LBPfkFgFn3YlHp0eoywNpm57MFxWx8u3U2Hkw==
+-----END RSA PRIVATE KEY-----
+"""
+
+
+# The following fields were removed from the test fixtures:
+#   getInfo: contacts, authcode, registrationDate, renewalDate
+# using:
+#   find tests/fixtures/cassettes/transip/ -name \*.json -exec sed -i 's/<contacts.*<\/contacts>//g' '{}' \;
+#   find tests/fixtures/cassettes/transip/ -name \*.json -exec sed -i 's/<authCode.*<\/authCode>//g' '{}' \;
+#   find tests/fixtures/cassettes/transip/ -name \*.json -exec sed -i 's/<registrationDate.*<\/registrationDate>//g' '{}' \;
+#   find tests/fixtures/cassettes/transip/ -name \*.json -exec sed -i 's/<renewalDate.*<\/renewalDate>//g' '{}' \;
+
+
+# Hook into testing framework by inheriting unittest.TestCase and reuse
+# the tests which *each and every* implementation of the interface must
+# pass, by inheritance from define_tests.TheTests
+class TransipProviderTests(TestCase, IntegrationTests):
+    Provider = Provider
+    provider_name = 'transip'
+    domain = 'hurrdurr.nl'
+
+    # Disable setUp and tearDown, and set a real username and key in
+    # provider_opts to execute real calls
+
+    provider_opts = {
+        'auth_username': 'foo',
+        'auth_api_key': 'None'
+    }
+
+    @classmethod
+    def setUpClass(cls):
+        cls.old_serializer = provider_vcr.serializer
+        provider_vcr.serializer = "json"
+
+    @classmethod
+    def tearDownClass(cls):
+        provider_vcr.serializer = cls.old_serializer
+
+    def setUp(self):
+        (_fake_fd, _fake_key) = mkstemp()
+        _fake_file = os.fdopen(_fake_fd, 'wb', 1024)
+        _fake_file.write(FAKE_KEY)
+        _fake_file.close()
+        self._fake_key = _fake_key
+        self.provider_opts['auth_api_key'] = _fake_key
+
+    def tearDown(self):
+        os.unlink(self._fake_key)
+
+    def _filter_headers(self):
+        return ['Cookie']
+
+    def _cassette_path(self, fixture_subpath):
+        path = super(TransipProviderTests, self)._cassette_path(fixture_subpath)
+        return path.replace(".yaml", ".json")
+
+    @pytest.mark.skip(reason="manipulating records by id is not supported")
+    def test_Provider_when_calling_delete_record_by_identifier_should_remove_record(self):
+        return
+
+    @pytest.mark.skip(reason="adding docs.example.com as a CNAME target will result in a RFC 1035 error")
+    def test_Provider_when_calling_create_record_for_CNAME_with_valid_name_and_content(self):
+        return