diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..335ea9d --- /dev/null +++ b/LICENCE @@ -0,0 +1,19 @@ +Copyright (c) 2018 The Python Packaging Authority + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..589ece1 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +python = python +ifdef PYTHON + python = ${PYTHON} +endif + +twine = ${python} -m twine +pip = ${python} -m pip + +upload: build + @${twine} upload dist/* -r testpypi + +build: + rm -rf build + rm -rf dist + ${python} setup.py sdist + ${python} setup.py bdist_wheel diff --git a/README.md b/README.md new file mode 100644 index 0000000..38b332c --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +## microsoftdnsserver-py + +microsoftdnsserver-py is a wrapper Python library for [DnsServer](https://docs.microsoft.com/en-us/powershell/module/dnsserver/?view=win10-ps) module. + +Subprocess module is used to perform process calls to interact with DnsServer module. + +## Features + - Convenient as a Python library + - Supports A and Txt record + - Query DNS records + +## Installation + +```shell +``` + +## Limitations + - Libray is not able to work remotely, currently, it is able to call DnsServer module on localhost. + Since, the remote session feature is not on the table, the software that uses this module + must be installed on windows server where Microsoft Dns Server is located. diff --git a/main.py b/main.py deleted file mode 100755 index 50655bf..0000000 --- a/main.py +++ /dev/null @@ -1,10 +0,0 @@ -from microsoftdnsserver.command_runner.powershell_runner import PowerShellRunner -from microsoftdnsserver.dns.dnsserver import DnsServerModule - -if __name__ == '__main__': - runner = PowerShellRunner() - dns = DnsServerModule(runner) - -# dns.addARecord("zone", "bilalekrem.com", "192.168.100.150", ttl='10s') - - dns.addTxtRecord("bilalekrem.com", "bilalekrem.com", "testrecord", ttl='10s') diff --git a/microsoftdnsserver/command_runner/powershell_runner.py b/microsoftdnsserver/command_runner/powershell_runner.py index 8efe806..bd9c2b7 100644 --- a/microsoftdnsserver/command_runner/powershell_runner.py +++ b/microsoftdnsserver/command_runner/powershell_runner.py @@ -1,15 +1,15 @@ import subprocess -import sys from .runner import Command, CommandRunner, Result +from ..util import logger -DEBUG = True -POWERSHELL_EXE_PATH="C:\Windows\syswow64\WindowsPowerShell\\v1.0\powershell.exe" +DEFAULT_POWERSHELL_EXE_PATH = "C:\Windows\syswow64\WindowsPowerShell\\v1.0\powershell.exe" + class PowerShellCommand(Command): - def __init__(self, cmdlet, *flags, **args): + def __init__(self, cmdlet: str, *flags, **args): super().__init__() self.cmdlet = cmdlet @@ -17,7 +17,7 @@ def __init__(self, cmdlet, *flags, **args): self.args = args def prepareCommand(self): - cmd = [POWERSHELL_EXE_PATH, self.cmdlet] + cmd = [self.cmdlet] # add flags, ie -Force for flag in self.flags: @@ -39,26 +39,37 @@ def _postProcessResult(self): class PowerShellRunner(CommandRunner): - def run(self, command): - assert (command, PowerShellCommand) + def __init__(self, powerShellPath: str = None): + self.logger = logger.createLogger("PowerShellRunner") + + self.powerShellPath = powerShellPath + if powerShellPath is None: + self.powerShellPath = DEFAULT_POWERSHELL_EXE_PATH + + def run(self, command: PowerShellCommand) -> Result: + assert isinstance(command, PowerShellCommand) cmd = command.prepareCommand() + cmd.insert(0, self.powerShellPath) - if DEBUG: - print(' '.join(cmd)) - return + self.logger.debug("Running: [%s]" % ' '.join(cmd)) - proc = subprocess.Popen(cmd, stdout=sys.stdout) + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) try: - out, _ = proc.communicate(timeout=60) + out, err = proc.communicate(timeout=60) except: proc.kill() - out, _ = proc.communicate() + out, err = proc.communicate() finally: pass + out = out.decode('utf-8') + err = err.decode('utf-8') + + self.logger.debug("Returned: \n\tout:[%s], \n\terr:[%s]" % (out, err)) + success = proc.returncode == 0 - return Result(success, proc.returncode, out) + return Result(success, proc.returncode, out, err) diff --git a/microsoftdnsserver/command_runner/runner.py b/microsoftdnsserver/command_runner/runner.py index 43290a9..b4e6c96 100644 --- a/microsoftdnsserver/command_runner/runner.py +++ b/microsoftdnsserver/command_runner/runner.py @@ -9,13 +9,14 @@ def prepareCommand(self): class CommandRunner(object): - def run(self, cmd): + def run(self, cmd: Command): raise MethodNotImplementedError() class Result(object): - def __init__(self, success, code, out): + def __init__(self, success: bool, code: int, out: str, err: str): self.success = success self.code = code self.out = out + self.err = err diff --git a/microsoftdnsserver/dns/base.py b/microsoftdnsserver/dns/base.py index be6cef2..54ceb18 100644 --- a/microsoftdnsserver/dns/base.py +++ b/microsoftdnsserver/dns/base.py @@ -1,3 +1,6 @@ +from typing import List + +from microsoftdnsserver.dns.record import Record, RecordType from microsoftdnsserver.exception.exception_common import MethodNotImplementedError @@ -6,11 +9,17 @@ class DNSService(object): def __init__(self): pass - def getDNSRecords(self, zone, name, recordType): + def getDNSRecords(self, zone: str, name: str, recordType: RecordType) -> List[Record]: + raise MethodNotImplementedError() + + def addARecord(self, zone: str, name: str, ip: str, ttl: str) -> bool: raise MethodNotImplementedError() - def addARecord(self, zone, name, ip, ttl, ageRecord): + def removeARecord(self, zone: str, name: str) -> bool: raise MethodNotImplementedError() - def removeARecord(self, zone, name, recordData): + def addTxtRecord(self, zone: str, name: str, content, ttl: str) -> bool: raise MethodNotImplementedError() + + def removeTxtRecord(self, zone: str, name: str) -> bool: + raise MethodNotImplementedError() \ No newline at end of file diff --git a/microsoftdnsserver/dns/dnsserver.py b/microsoftdnsserver/dns/dnsserver.py index c112665..b73ab4c 100644 --- a/microsoftdnsserver/dns/dnsserver.py +++ b/microsoftdnsserver/dns/dnsserver.py @@ -1,8 +1,11 @@ -import logging +import json -from microsoftdnsserver.command_runner.powershell_runner import PowerShellCommand +from microsoftdnsserver.command_runner.runner import Command, CommandRunner +from microsoftdnsserver.command_runner.powershell_runner import PowerShellCommand, PowerShellRunner from .base import DNSService -from ..util import dns_server_utils +from .record import RecordType +from ..util import dns_server_utils, logger + class DnsServerModule(DNSService): """ @@ -11,26 +14,15 @@ class DnsServerModule(DNSService): https://docs.microsoft.com/en-us/powershell/module/dnsserver/?view=win10-ps """ - def __init__(self, runner): + def __init__(self, runner: CommandRunner = None): super().__init__() self.runner = runner + if runner is None: + self.runner = PowerShellRunner() - self.logger = None - pass - - def _initLogger(self): - handler = logging.StreamHandler() - formatter = logging.Formatter('%(asctime)s %(name)-12s %(levelname)-8s %(message)s') - handler.setFormatter(formatter) - - logger = logging.getLogger() - logger.addHandler(handler) - logger.setLevel(logging.DEBUG) - - self.logger = logger - + self.logger = logger.createLogger("DnsServer") - def getDNSRecords(self, zone, name=None, recordType=None): + def getDNSRecords(self, zone: str, name: str = None, recordType: RecordType = None): """ uses Get-DnsServerResourceRecord cmdlet to get records in a zone """ args = { @@ -40,13 +32,15 @@ def getDNSRecords(self, zone, name=None, recordType=None): if name: args['Name'] = name if recordType: - args['RRType'] = recordType + args['RRType'] = recordType.value command = PowerShellCommand('Get-DnsServerResourceRecord', **args) - self.run(command) + result = self.run(command) + jsonResult = json.loads(result.out) + return dns_server_utils.formatDnsServerResult(zone, jsonResult) - def addARecord(self, zone, name, ip, ttl='1h', ageRecord=False): + def addARecord(self, zone: str, name: str, ip: str, ttl: str = '1h'): """ uses Add-DnsServerResourceRecordA cmdlet to add a resource in a zone """ command = PowerShellCommand( @@ -58,9 +52,10 @@ def addARecord(self, zone, name, ip, ttl='1h', ageRecord=False): TimeToLive=dns_server_utils.formatTtl(ttl) ) - self.run(command) + result = self.run(command) + return result.success - def removeARecord(self, zone, name, recordData=None): + def removeARecord(self, zone: str, name: str): """ uses Remove-DnsServerResourceRecord cmdlet to remove a record in a zone """ args = { @@ -69,17 +64,17 @@ def removeARecord(self, zone, name, recordData=None): } if name: args['Name'] = name - if recordData: - args['RecordData'] = recordData flags = ['Force'] command = PowerShellCommand('Remove-DnsServerResourceRecord', *flags, **args) - self.run(command) + result = self.run(command) + + return result # --- - def addTxtRecord(self, zone, name, content, ttl='1h'): + def addTxtRecord(self, zone: str, name: str, content: str, ttl: str = '1h'): """ uses Add-DnsServerResourceRecord cmdlet to add txt resource in a zone """ command = PowerShellCommand( @@ -92,9 +87,11 @@ def addTxtRecord(self, zone, name, content, ttl='1h'): TimeToLive=dns_server_utils.formatTtl(ttl) ) - self.run(command) + result = self.run(command) + + return result.success - def removeTxtRecord(self, zone, name, recordData=None): + def removeTxtRecord(self, zone: str, name: str): """ uses Remove-DnsServerResourceRecord cmdlet to remove txt record in a zone """ args = { @@ -103,22 +100,24 @@ def removeTxtRecord(self, zone, name, recordData=None): } if name: args['Name'] = name - if recordData: - args['RecordData'] = recordData flags = ['Force'] command = PowerShellCommand('Remove-DnsServerResourceRecord', *flags, **args) - self.run(command) + result = self.run(command) + + return result.success # -- - def run(self, command): + def run(self, command: Command): result = self.runner.run(command) if not result.success: self.logger.error("Command failed [%s]" % command.prepareCommand()) + return result + def isDnsServerModuleInstalled(self): cmdlet = "Get-Module DNSServer -ListAvailable" result = self.runner.run(cmdlet) diff --git a/microsoftdnsserver/dns/record.py b/microsoftdnsserver/dns/record.py index cf81694..03fdda6 100644 --- a/microsoftdnsserver/dns/record.py +++ b/microsoftdnsserver/dns/record.py @@ -1,8 +1,27 @@ +from enum import Enum + + +class RecordType(Enum): + A = 'A' + TXT = 'Txt' + + @staticmethod + def list(): + return [t.value for t in RecordType] + + @staticmethod + def value_of(value): + return next((t for t in RecordType if t.value == value)) + + class Record(object): - def __init__(self, zone, name, type, content, ttl=1): + def __init__(self, zone: str, name: str, recordType: RecordType, content: str, ttl: str = '1h'): self.zone = zone self.name = name - self.type = type + self.type = recordType self.content = content - self.ttl = ttl \ No newline at end of file + self.ttl = ttl + + def __repr__(self): + return '[%s] record - zone: [%s], name: [%s]' % (self.type, self.zone, self.name) diff --git a/microsoftdnsserver/util/dns_server_utils.py b/microsoftdnsserver/util/dns_server_utils.py index f6df266..e70bccd 100644 --- a/microsoftdnsserver/util/dns_server_utils.py +++ b/microsoftdnsserver/util/dns_server_utils.py @@ -1,3 +1,8 @@ +from collections.abc import Iterable + +from ..dns.record import Record, RecordType + + def formatTtl(ttl): """ formatTtl converts given ttl string to Windows-DnsServer module's @@ -16,6 +21,9 @@ def formatTtl(ttl): :param ttl: time to live :return: formatted time to live string for Windows-DnsServer module """ + assert isinstance(ttl, str) + assert len(ttl) > 0, "empty ttl value" + hour = 0 minute = 0 seconds = 0 @@ -25,32 +33,76 @@ def formatTtl(ttl): if 'h' in unit: hour = int(unit[:-1]) continue - elif 'm' in ttl: + elif 'm' in unit: minute = int(unit[:-1]) continue - elif 's' in ttl: + elif 's' in unit: seconds = int(unit[:-1]) continue raise Exception("time unit could not be determined [%s]" % unit) - assert hour >= 0 and minute >= 0 and seconds >=0, ' Time unit can not be negative' + assert hour < 24, 'hour can not be more than 23' + assert minute < 60, 'minute can not be more than 59' + assert seconds < 60, 'seconds can not be more than 59' + + assert hour >= 0 and minute >= 0 and seconds >= 0, 'Time unit can not be negative' assert hour > 0 or minute > 0 or seconds > 0, 'At least one time unit must be provided' return '%02d:%02d:%02d' % (hour, minute, seconds) -def formatDnsServerResult(result): - response = { - 'DistinguishedName': result['DistinguishedName'], - 'HostName': result['HostName'], - 'RecordClass': result['RecordClass'], - 'RecordType': result['RecordType'], - 'TimeToLive': result['TimeToLive']['TotalMilliseconds'] - } - - # -- - recordDataProperties = result['RecordData']['CimInstanceProperties'] - _, value = recordDataProperties.split('=') - response['RecordData'] = value - - return response + +def parseTtl(timeToLive): + hours = timeToLive['Hours'] + minutes = timeToLive['Minutes'] + seconds = timeToLive['Seconds'] + + ttl_str = '' + if hours: + ttl_str += '%sh ' % hours + + if minutes: + ttl_str += '%sm ' % minutes + + if seconds: + ttl_str += '%ss ' % seconds + + return ttl_str[:-1] + + +def isRecordTypeSupported(recordType): + return recordType in RecordType.list() + + +def formatDnsServerResult(zone, cmdletResults): + if not isinstance(cmdletResults, list): + cmdletResults = [cmdletResults] + + recordResults = [] + for result in cmdletResults: + name = result['HostName'] + recordType = result['RecordType'] + + if not isRecordTypeSupported(recordType): + continue + + recordDataProperties = result['RecordData']['CimInstanceProperties'] + + recordData = dict() + if isinstance(recordDataProperties, str): + key, value = recordDataProperties.split('=') + # value's has at begin and end, remove it + recordData[key.strip()] = value[1:-1] + else: + for props in recordDataProperties: + key, value = props.split('=') + recordData[key.strip()] = value + + assert len(recordData) < 2, "Unexpected data, expected only one record data property, actual: [%s]" % recordData + + content = next(iter(recordData.values())) + ttl = parseTtl(result['TimeToLive']) + + recordResults.append(Record(zone, name, RecordType.value_of(recordType), content, ttl)) + + return recordResults diff --git a/microsoftdnsserver/util/logger.py b/microsoftdnsserver/util/logger.py new file mode 100644 index 0000000..3592b68 --- /dev/null +++ b/microsoftdnsserver/util/logger.py @@ -0,0 +1,13 @@ +import logging + + +def createLogger(name): + handler = logging.StreamHandler() + formatter = logging.Formatter('%(asctime)s %(name)-12s %(levelname)-8s %(message)s') + handler.setFormatter(formatter) + + logger = logging.getLogger(name) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + + return logger diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7d0b5aa --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +import setuptools + +with open("README.md", "r") as fd: + long_description = fd.read() + +setuptools.setup( + name="microsoftdnsserver-py", + version="0.0.1", + author="Bilal Ekrem Harmansa", + author_email="bilalekremharmansa@gmail.com", + description="wrapper Python library for DnsServer module", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/bilalekremharmansa/microsoftdnsserver-py", + packages=setuptools.find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + python_requires='>=3.5', +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mock_data.json b/tests/mock_data.json new file mode 100644 index 0000000..69e4f36 --- /dev/null +++ b/tests/mock_data.json @@ -0,0 +1,3 @@ +{ + "GetDnsServerResponse1": "{\"DistinguishedName\":\"@\",\"HostName\":\"@\",\"RecordClass\":\"IN\",\"RecordData\":{\"CimClass\":\"root/Microsoft/Windows/DNS:DnsServerResourceRecordA\",\"CimInstanceProperties\":\"IPv4Address=\\\"34.65.234.38\\\"\",\"CimSystemProperties\":\"Microsoft.Management.Infrastructure.CimSystemProperties\"},\"RecordType\":\"A\",\"Timestamp\":null,\"TimeToLive\":{\"Ticks\":36000000000,\"Days\":0,\"Hours\":1,\"Milliseconds\":0,\"Minutes\":0,\"Seconds\":0,\"TotalDays\":0.041666666666666664,\"TotalHours\":1,\"TotalMilliseconds\":3600000,\"TotalMinutes\":60,\"TotalSeconds\":3600},\"PSComputerName\":null}" +} \ No newline at end of file diff --git a/tests/test_dns_server.py b/tests/test_dns_server.py new file mode 100644 index 0000000..e9768b1 --- /dev/null +++ b/tests/test_dns_server.py @@ -0,0 +1,42 @@ +import unittest + + +from microsoftdnsserver.dns.dnsserver import DnsServerModule +from microsoftdnsserver.dns.record import RecordType + + +class TestDnsServer(unittest.TestCase): + + def setUp(self): + self.dns = DnsServerModule() + + self.test_dns_zone = "myzone.com" + self.test_dns_name = "www" + + def test_get_dns_records(self): + success = self.dns.addARecord(self.test_dns_zone, self.test_dns_name, "100.100.100.100") + self.assertTrue(success, "failed while adding test record") + + records = self.dns.getDNSRecords(self.test_dns_zone, self.test_dns_name, RecordType.A) + self.assertGreater(len(records), 0) + + for record in records: + self.assertEqual(record.type, RecordType.A) + + self.dns.removeARecord(self.test_dns_zone, self.test_dns_name) + + def test_add_record_a(self): + ip_addr = "10.0.0.99" + + success = self.dns.addARecord(self.test_dns_zone, self.test_dns_name, ip_addr) + self.assertTrue(success) + + def test_add_record_txt(self): + txt = "my test record" + + success = self.dns.addTxtRecord(self.test_dns_zone, self.test_dns_name, txt) + self.assertTrue(success) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_dns_server_utils.py b/tests/test_dns_server_utils.py new file mode 100644 index 0000000..138004e --- /dev/null +++ b/tests/test_dns_server_utils.py @@ -0,0 +1,93 @@ +import unittest +import json + +from unittest.mock import patch + +from microsoftdnsserver.command_runner.runner import Result +from microsoftdnsserver.dns.dnsserver import DnsServerModule +from microsoftdnsserver.dns.record import RecordType +from microsoftdnsserver.util.dns_server_utils import parseTtl, formatTtl + + +class TestDnsServerUtils(unittest.TestCase): + + def test_convert_dns_server(self): + mock_data = self.load_mock_data() + + with patch('microsoftdnsserver.dns.dnsserver.DnsServerModule.run') as mock: + mock.return_value = Result(True, 0, mock_data['GetDnsServerResponse1'], '') + + dns = DnsServerModule() + results = dns.getDNSRecords("zone") + print(results) + self.assertEqual(len(results), 1) + + result = results[0] + self.assertEqual(result.zone, "zone") + self.assertEqual(result.name, "@") + self.assertEqual(result.type, RecordType.A) + self.assertEqual(result.content, '34.65.234.38') + + def test_parse_ttl(self): + def mock_time_to_live(h, m, s): + mock = dict() + + mock['Hours'] = h + + mock['Minutes'] = m + + mock['Seconds'] = s + return mock + + self.assertEqual(parseTtl(mock_time_to_live(1, 23, 50)), '1h 23m 50s') + + self.assertEqual(parseTtl(mock_time_to_live(0, 23, 50)), '23m 50s') + self.assertEqual(parseTtl(mock_time_to_live(1, 0, 50)), '1h 50s') + self.assertEqual(parseTtl(mock_time_to_live(1, 23, 0)), '1h 23m') + self.assertEqual(parseTtl(mock_time_to_live(1, 50, 2)), '1h 50m 2s') + + def test_format_ttl(self): + self.assertEqual(formatTtl('10h 20m 30s'), '10:20:30') + self.assertEqual(formatTtl('10h 30s'), '10:00:30') + self.assertEqual(formatTtl('30s'), '00:00:30') + self.assertEqual(formatTtl('20m 10h 30s'), '10:20:30') + self.assertEqual(formatTtl('0s 10h 20m'), '10:20:00') + + with self.assertRaisesRegex(Exception, 'time unit could not be determined'): + formatTtl('10n') + + with self.assertRaisesRegex(AssertionError, "empty ttl value"): + formatTtl('') + + with self.assertRaises(AssertionError): + formatTtl(111) + + with self.assertRaises(Exception): + formatTtl('10h 20m 30') + + with self.assertRaises(Exception): + formatTtl('10h 20') + + with self.assertRaises(Exception): + formatTtl('0h 0m 0s') + + self.assertEqual(formatTtl('0h 0m 1s'), '00:00:01') + + with self.assertRaisesRegex(AssertionError, 'hour can not be more than'): + formatTtl('25h') + + with self.assertRaisesRegex(AssertionError, 'minute can not be more than'): + formatTtl('90m') + + with self.assertRaisesRegex(AssertionError, 'seconds can not be more than'): + formatTtl('100s') + + def load_mock_data(self): + fd = open('/tests/mock_data.json') + r = json.load(fd) + fd.close() + return r + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_records.py b/tests/test_records.py new file mode 100644 index 0000000..5136b0e --- /dev/null +++ b/tests/test_records.py @@ -0,0 +1,27 @@ +import unittest + +from microsoftdnsserver.dns.record import RecordType +from microsoftdnsserver.util import dns_server_utils + + +class TestRecords(unittest.TestCase): + + def test_supported_record_types(self): + self.assertTrue(dns_server_utils.isRecordTypeSupported('A')) + self.assertTrue(dns_server_utils.isRecordTypeSupported('Txt')) + + self.assertFalse(dns_server_utils.isRecordTypeSupported('SOA')) + self.assertFalse(dns_server_utils.isRecordTypeSupported('TXT')) + + def test_record_type_value_of(self): + record_type_a = RecordType.value_of('A') + + self.assertEqual(RecordType.A, record_type_a) + + record_type_txt = RecordType.value_of('Txt') + + self.assertEqual(RecordType.TXT, record_type_txt) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file