diff --git a/OpenTrepWrapper.py b/OpenTrepWrapper.py deleted file mode 100644 index 73e6c78..0000000 --- a/OpenTrepWrapper.py +++ /dev/null @@ -1,412 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -''' -This module is an OpenTrep binding. - - >>> from OpenTrepWrapper import main_trep, index_trep - >>> from OpenTrepWrapper import DEFAULT_LOG, DEFAULT_FMT, DEFAULT_IDX, DEFAULT_POR - - >>> index_trep (porPath = '/tmp/opentraveldata/optd_por_public_all.csv', \ - xapianDBPath = '/tmp/opentrep/xapian_traveldb', \ - logFilePath = '/tmp/opentrep/opeentrep-indexer.log', \ - verbose = False) - - >>> main_trep (searchString = 'nce sfo', \ - outputFormat = 'S', \ - xapianDBPath = '/tmp/opentrep/xapian_traveldb', \ - logFilePath = '/tmp/opentrep/opeentrep-searcher.log', \ - verbose = False) - ([(89.8466, 'NCE'), (357.45599999999996, 'SFO')], '') - -''' - -from __future__ import with_statement - -import json -import sys -import os, errno - -try: - # For 64 bits system, if not in site-packages - sys.path.append ('/usr/lib64') - - # Initialise the OpenTrep C++ library - import pyopentrep - -except ImportError: - # pyopentrep could not be found - raise ImportError("*pyopentrep* raised ImportError.") - - -# Default settings -DEFAULT_POR = '/tmp/opentraveldata/optd_por_public.csv' -DEFAULT_IDX = '/tmp/opentrep/xapian_traveldb' -DEFAULT_FMT = 'S' -DEFAULT_LOG = '/tmp/opentrep/opentrepwrapper.log' -FLAG_INDEX_NON_IATA_POR = False -FLAG_INIT_XAPIAN = True -FLAG_ADD_POR_TO_DB = True - -AVAILABLE_FORMATS = set(['I', 'J', 'F', 'S']) - - - -class OpenTrepLib(object): - ''' - This class wraps the methods of the C++ OpenTrepSearcher class. - - >>> otp = OpenTrepLib(DEFAULT_POR, DEFAULT_IDX, DEFAULT_LOG) - - >>> otp.search('san francsico los angeles', DEFAULT_FMT) - ([(0.32496, 'SFO'), (0.5450269999999999, 'LAX')], '') - - >>> otp.finalize() - ''' - - def __init__(self, porPath=DEFAULT_POR, xapianDBPath=DEFAULT_IDX, logFilePath=DEFAULT_LOG): - - if not os.path.isdir(xapianDBPath): - # If xapianDBPath is not a directory, - # it probably means that the database has - # never been created - # First we create the path to avoid failure next - print('/!\ Directory {} did not exist, creating...\n'.format(xapianDBPath)) - mkdir_p(xapianDBPath) - - self._trep_lib = pyopentrep.OpenTrepSearcher () - - # sqlDBType = 'sqlite' - # sqlDBConnStr = '/tmp/opentrep/sqlite_travel.db' - sqlDBType = 'nodb' - sqlDBConnStr = '' - deploymentNumber = 0 - xapianDBActualPath = xapianDBPath + str(deploymentNumber) - initOK = self._trep_lib.init (porPath, xapianDBPath, - sqlDBType, sqlDBConnStr, - deploymentNumber, FLAG_INDEX_NON_IATA_POR, - FLAG_INIT_XAPIAN, FLAG_ADD_POR_TO_DB, logFilePath) - - if not initOK: - msgStr = 'Xapian index: {}; SQL DB type: {}; Deployment: {}; log file: {}'.format (xapianDBPath, sqlDBType, deploymentNumber, logFilePath) - raise Exception('The OpenTrep library cannot be initialised - {}'.format(msgStr)) - - if not os.listdir(xapianDBActualPath): - # Here it seems that the xapianDBPath is empty, - # this is the case if the POR data file has not been indexed yeet. - # So we index the POR data file now with Xapian. - print('/!\ {} seems to be empty, forcing indexation now...\n'.format(xapianDBPath)) - self.index(verbose=True) - - - def finalize(self): - ''' - Free the OpenTREP library resource - ''' - self._trep_lib.finalize() - - - def __enter__(self): - '''To be used in with statements. - ''' - return self - - - def __exit__(self, type_, value, traceback): - '''On de-indent inside with statement. - ''' - self.finalize() - - - def getPaths(self): - ''' - File-paths details - ''' - # Calls the underlying OpenTrep library service - filePathList = self._trep_lib.getPaths().split(';') - - # Report the results - print("ORI-maintained list of POR (points of reference): '%s'" % filePathList[0]) - print("Xapian-based travel database/index: '%s'" % filePathList[1]) - - - def index(self, verbose=False): - ''' - Indexation - ''' - if verbose: - print("Perform the indexation of the (Xapian-based) travel database.") - print("That operation may take several minutes on some slow machines.") - print("It takes less than 20 seconds on fast ones...") - - # Calls the underlying OpenTrep library service - result = self._trep_lib.index() - - if verbose: - # Report the results - print("Done. Indexed %s POR (points of reference)" % result) - - - - def search(self, searchString, outputFormat=DEFAULT_FMT, verbose=False): - '''Search. - - If no search string was supplied as arguments of the command-line, - ask the user for some - - Call the OpenTrep C++ library. - - The 'I' (Interpretation from JSON) output format is just an example - of how to use the output generated by the OpenTrep library. Hence, - that latter does not support that "output format". So, the raw JSON - format is required, and the JSON string will then be parsed and - interpreted by the jsonResultParser() method, just to show how it - works - ''' - - if outputFormat not in AVAILABLE_FORMATS: - raise ValueError('outputFormat "%s" invalid, not in %s.' % \ - (outputFormat, AVAILABLE_FORMATS)) - - opentrepOutputFormat = outputFormat - - if opentrepOutputFormat == 'I': - opentrepOutputFormat = 'J' - - result = self._trep_lib.search(opentrepOutputFormat, searchString) - - # When the compact format is selected, the result string has to be - # parsed accordingly. - if outputFormat == 'S': - fmt_result = compactResultParser(result) - - # When the full details have been requested, the result string is - # potentially big and complex, and is not aimed to be - # parsed. So, the result string is just displayed/dumped as is. - elif outputFormat == 'F': - fmt_result = result - - # When the raw JSON format has been requested, no handling is necessary. - elif outputFormat == 'J': - fmt_result = result - - # The interpreted JSON format is an example of how to extract relevant - # information from the corresponding Python structure. That code can be - # copied/pasted by clients to the OpenTREP library. - elif outputFormat == 'I': - fmt_result = jsonResultParser(result) - - if verbose: - print(' -> Raw result: %s' % result) - print(' -> Fmt result: %s' % str(fmt_result)) - - return fmt_result - - - -def compactResultParser(resultString): - ''' - Compact result parser. The result string contains the main matches, - separated by commas (','), along with their associated weights, given - as percentage numbers. For every main match: - - - Columns (':') separate potential extra matches (i.e., matches with the same - matching percentage). - - Dashes ('-') separate potential alternate matches (i.e., matches with lower - matching percentages). - - Samples of result string to be parsed: - - % python3 pyopentrep.py -f S nice sna francisco vancouver niznayou - 'nce/100,sfo/100-emb/98-jcc/97,yvr/100-cxh/83-xea/83-ydt/83;niznayou' - % python3 pyopentrep.py -f S fr - 'aur:avf:bae:bou:chr:cmf:cqf:csf:cvf:dij/100' - - >>> test_1 = 'nce/100,sfo/100-emb/98-jcc/97,yvr/100-cxh/83-xea/83-ydt/83;niznayou' - >>> compactResultParser(test_1) - ([(1.0, 'NCE'), (1.0, 'SFO'), (1.0, 'YVR')], 'niznayou') - - >>> test_2 = 'aur:avf:bae:bou:chr:cmf:cqf:csf:cvf:dij/100' - >>> compactResultParser(test_2) - ([(1.0, 'AUR')], '') - - >>> test_3 = ';eeee' - >>> compactResultParser(test_3) - ([], 'eeee') - ''' - - # Strip out the unrecognised keywords - if ';' in resultString: - str_matches, unrecognized = resultString.split(';', 1) - else: - str_matches, unrecognized = resultString, '' - - if not str_matches: - return [], unrecognized - - - codes = [] - - for alter_loc in str_matches.split(','): - - for extra_loc in alter_loc.split('-'): - - extra_loc, score = extra_loc.split('/', 1) - - for code in extra_loc.split(':'): - - codes.append((float(score) / 100.0, code.upper())) - - # We break because we only want to first - break - - # We break because we only want to first - break - - return codes, unrecognized - - - -def jsonResultParser(resultString): - ''' - JSON interpreter. The JSON structure contains a list with the main matches, - along with their associated fields (weights, coordinates, etc). - For every main match: - - - There is a potential list of extra matches (i.e., matches with the same - matching percentage). - - There is a potential list of alternate matches (i.e., matches with lower - matching percentages). - - Samples of result string to be parsed: - - - python3 pyopentrep.py -f J nice sna francisco - - {'locations':[ - {'names':[ - {'name': 'nice'}, {'name': 'nice/fr:cote d azur'}], - 'city_code': 'nce'}, - {'names':[ - {'name': 'san francisco'}, {'name': 'san francisco/ca/us:intl'}], - 'city_code': 'sfo', - 'alternates':[ - {'names':[ - {'name': 'san francisco emb'}, - {'name': 'san francisco/ca/us:embarkader'}], - 'city_code': 'sfo'}, - {'names':[ - {'name': 'san francisco jcc'}, - {'name': 'san francisco/ca/us:china hpt'}], - 'city_code': 'sfo'} - ]} - ]} - - - python3 pyopentrep.py -f J fr - - {'locations':[ - {'names':[ - {'name': 'aurillac'}, {'name': 'aurillac/fr'}], - 'extras':[ - {'names':[ - {'name': 'avoriaz'}, {'name': 'avoriaz/fr'}], - 'city_code': 'avf'}, - {'names':[ - {'name': 'barcelonnette'}, {'name': 'barcelonnette/fr'}], - 'city_code': 'bae'} - ]} - ]} - - >>> res = """{ "locations":[{ - ... "iata_code": "ORY", - ... "icao_code": "LFPO", - ... "city_code": "PAR", - ... "geonames_id": "2988500", - ... "lon": "2.359444", - ... "lat": "48.725278", - ... "page_rank": "23.53" - ... }, { - ... "iata_code": "CDG", - ... "icao_code": "LFPG", - ... "city_code": "PAR", - ... "geonames_id": "6269554", - ... "lon": "2.55", - ... "lat": "49.012779", - ... "page_rank": "64.70" - ... }] - ... }""" - >>> print(jsonResultParser(res)) - ORY-LFPO-2988500-23.53%-PAR-48.73-2.36; CDG-LFPG-6269554-64.70%-PAR-49.01-2.55 - ''' - - return '; '.join( - '-'.join([ - loc['iata_code'], - loc['icao_code'], - loc['geonames_id'], - '%.2f%%' % float(loc['page_rank']), - loc['cities']['city_details']['iata_code'], - '%.2f' % float(loc['lat']), - '%.2f' % float(loc['lon']) - ]) - for loc in json.loads(resultString)['locations'] - ) - - - -def mkdir_p(path): - ''' - mkdir -p behavior. - ''' - try: - os.makedirs(path) - except OSError as exc: # Python >2.5 - if exc.errno == errno.EEXIST and os.path.isdir(path): - pass - else: - raise - - -def index_trep (porPath = DEFAULT_POR, - xapianDBPath = DEFAULT_IDX, - logFilePath = DEFAULT_LOG, - verbose=False): - ''' - Instanciate the OpenTrepLib object and index. - ''' - with OpenTrepLib(porPath, xapianDBPath, logFilePath) as otp: - otp.index(verbose) - - -def main_trep (searchString, - outputFormat = DEFAULT_FMT, - xapianDBPath = DEFAULT_IDX, - logFilePath = DEFAULT_LOG, - verbose = False): - ''' - Instanciate the OpenTrepLib object and search from it. - - >>> main_trep (searchString = 'san francisco', \ - outputFormat = 'S', \ - xapianDBPath = '/tmp/opentrep/xapian_traveldb', \ - logFilePath = '/tmp/opentrep/opeentrep-searcher.log', \ - verbose = False) - ([(0.32496, 'SFO')], '') - - ''' - with OpenTrepLib(DEFAULT_POR, xapianDBPath, logFilePath) as otp: - return otp.search(searchString, outputFormat, verbose) - - -def _test(): - ''' - Launching doctests. - ''' - import doctest - - opt = doctest.ELLIPSIS - - doctest.testmod(optionflags=opt) - - -if __name__ == '__main__': - - _test() - diff --git a/OpenTrepWrapper/OpenTrepWrapper.py b/OpenTrepWrapper/OpenTrepWrapper.py new file mode 100644 index 0000000..4450990 --- /dev/null +++ b/OpenTrepWrapper/OpenTrepWrapper.py @@ -0,0 +1,649 @@ +# -*- coding: utf-8 -*- +# +# Source: https://github.com/trep/wrapper/tree/master/OpenTrepWrapper/OpenTrepWrapper.py +# +# Authors: Alex Prengere, Denis Arnaud +# + +''' +This module is an OpenTrep binding. + + >>> import OpenTrepWrapper + + >>> otp = OpenTrepWrapper.OpenTrepLib() + + >>> otp.init_cpp_extension(por_path=None, + xapian_index_path="/tmp/opentrep/xapian_traveldb", + sql_db_type="nodb", + sql_db_conn_str=None, + deployment_nb=0, + log_path="test_OpenTrepWrapper.log", + log_level=5) + + >>> otp.index() + + >>> otp.search(search_string="nce sfo", outputFormat="S") + ([(89.8466, 'NCE'), (357.45599999999996, 'SFO')], '') + ------------------ + + >>> otp.finalize() + +''' + +from __future__ import with_statement + +import os +import sys +import inspect +import errno +import pathlib +import json + +class Error(Exception): + """ + Base class for other OpenTrep (OTP) exceptions + """ + pass + +class OPTInitError(Error): + """ + Raised when there is an issue initializing OenTrep Python eztension + """ + pass + +class PathCreationError(Error): + """ + Raised when there is an issue creating a directory structure + """ + pass + +class OutputFormatError(Error): + """ + Raised when the given output format is not in the list + """ + pass + +class SQLDBTypeError(Error): + """ + Raised when the given SQL database type is not in the list + """ + pass + +class OpenTrepLib(): + """ + This class wraps the methods of the OpenTrep Python extension + and a few utilities. + + Log levels: 1. Critical; 2. Errors; 3: Warnings; 4: Info; 5: Verbose + """ + + def __init__(self): + # Default settings + self.por_filepath = '/tmp/opentraveldata/optd_por_public_all.csv' + self.xapian_index_filepath = '/tmp/opentrep/xapian_traveldb' + self.sql_db_type_list = set(['nodb', 'sqlite', 'mysql']) + self.sql_db_type = 'nodb' + self.sql_db_conn_str = '' + self.deployment_nb = 0 + self.output_available_formats = set(['I', 'J', 'F', 'S']) + self.output_format = 'S' + self.log_filepath = '/tmp/opentrep/opentrepwrapper.log' + self.log_level = 2 + self.flag_index_non_iata_por = False + self.flag_init_xapian = True + self.flag_add_por_to_db = False + self._trep_lib = None + + def __str__(self): + """ + Description of the OpenTrepLib instance + """ + desc = f"Xapian index: {self.xapian_index_filepath}; " \ + f"SQL DB type: {self.sql_db_type}; " \ + f"Deployment: {self.deployment_nb}; " \ + f"log file: {self.log_filepath}" + return desc + + def get_log_pfx(self): + """ + Derive a prefix for logging purpose + """ + # 0 represents this line + # 1 represents line at caller + callerframerecord = inspect.stack()[1] + + frame = callerframerecord[0] + info = inspect.getframeinfo(frame) + filename = os.path.basename(info.filename) + log_pfx = f"[TREP][{filename}][{info.function}][{info.lineno}] -" + return log_pfx + + def derive_optd_por_test_filepath(self, pyotp_path=None): + otp_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(pyotp_path))))) + optd_por_test_filepath = f"{otp_dir}/share/opentrep/data/por/test_optd_por_public.csv" + return optd_por_test_filepath + + def init_cpp_extension(self, por_path=None, xapian_index_path=None, + sql_db_type=None, sql_db_conn_str=None, + deployment_nb=None, + log_path=None, log_level=None): + if por_path: + self.por_filepath = por_path + + if xapian_index_path: + self.xapian_index_filepath = xapian_index_path + + if sql_db_type: + if sql_db_type in self.sql_db_type_list: + self.sql_db_type = sql_db_type + else: + # + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The given SQL database type is " \ + f"not in the list of possible types: {self.sql_db_type_list}" + raise SQLDBTypeError(err_msg) + + if sql_db_conn_str: + self.sql_db_conn_str = sql_db_conn_str + + if deployment_nb: + self.deployment_nb = deployment_nb + + if log_path: + self.log_filepath = log_path + + if log_level: + self.log_level = log_level + + # + does_xapian_dir_exist = os.path.isdir(self.xapian_index_filepath) + if not does_xapian_dir_exist: + # If the directory hosting the Xapian index is not existing, it + # probably means that the Xapian index has not been created yet. + # First, the directory has to be created. + if self.log_level >= 4: + log_pfx = self.get_log_pfx() + print(f"{log_pfx} Directory {self.xapian_index_path} did not" \ + " exist, creating it") + self.mkdir_p(self.xapian_index_filepath) + + # + try: + # Initialise the OpenTrep Python extension + import pyopentrep + + except ImportError: + # + log_pfx = self.get_log_pfx() + pypi_url = "https://pypi.org/project/opentrep/" + err_msg = f"{log_pfx} Error - The OpenTrep Python externsion " \ + "cannot be properly initialized. See {pypi_url}" + raise ImportError(err_msg) + + self._trep_lib = pyopentrep.OpenTrepSearcher() + + # Derive the path to the test POR file + otpso_fp = inspect.getfile(pyopentrep) + optd_por_test_filepath = self.derive_optd_por_test_filepath(otpso_fp) + + if not por_path: + self.por_filepath = optd_por_test_filepath + + # sqlDBType = 'sqlite' + # sqlDBConnStr = '/tmp/opentrep/sqlite_travel.db' + xapianDBActualPath = f"{self.xapian_index_filepath}{self.deployment_nb}" + initOK = self._trep_lib.init (self.por_filepath, + self.xapian_index_filepath, + self.sql_db_type, + self.sql_db_conn_str, + self.deployment_nb, + self.flag_index_non_iata_por, + self.flag_init_xapian, + self.flag_add_por_to_db, + self.log_filepath) + + if not initOK: + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The OpenTrep Python extension " \ + f"cannot be initialized - OpenTrepLib object: {self}" + raise OPTInitError(err_msg) + + def finalize(self): + """ + Free the OpenTREP library resource + """ + if self._trep_lib: + self._trep_lib.finalize() + + def __enter__(self): + """ + To be used in with statements. + """ + return self + + def __exit__(self, type_, value, traceback): + """ + On de-indent inside with statement. + """ + if self._trep_lib: + self.finalize() + + def getPaths(self): + """ + File-paths details + """ + + # Check that the OpenTrep Python extension has been initialized + if not self._trep_lib: + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The OpenTrep Python extension has " \ + "not been initialized properly - OpenTrepLib: {self}" + raise OPTInitError(err_msg) + + # Calls the underlying OpenTrep library service + filePathStr = self._trep_lib.getPaths() + filePathList = filePathStr.split(';') + + # Report the results + optd_filepath = filePathList[0] + xapian_filepath = filePathList[1] + log_pfx = self.get_log_pfx() + print(f"{log_pfx} OPTD-maintained list of POR (points of reference): " \ + f"'{optd_filepath}'") + print(f"{log_pfx} Xapian-based travel database/index: " \ + f"'{xapian_filepath}'") + + def index(self): + ''' + Indexation + ''' + + # Check that the OpenTrep Python extension has been initialized + if not self._trep_lib: + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The OpenTrep Python extension has " \ + "not been initialized properly - OpenTrepLib: {self}" + raise OPTInitError(err_msg) + + if self.log_level >= 4: + print("Perform the indexation of the (Xapian-based) travel database.") + print("That operation may take several minutes on some slow machines.") + print("It takes less than 20 seconds on fast ones...") + + # Calls the underlying OpenTrep library service + result = self._trep_lib.index() + + if self.log_level >= 4: + # Report the results + print(f"Done. Indexed {result} POR (points of reference)") + + ## + # JSON interpreter. The JSON structure contains a list with the main matches, + # along with their associated fields (weights, coordinates, etc). + # For every main match: + # - There is a potential list of extra matches (i.e., matches with the same + # matching percentage). + # - There is a potential list of alternate matches (i.e., matches with lower + # matching percentages). + # + # Samples of result string to be parsed: + # - pyopentrep -fJ "nice sna francisco" + # - {'locations':[ + # {'names':[ + # {'name': 'Nice Airport'}, {'name': 'Nice Côte d'Azur International Airport'}], + # 'cities': { 'city_details': { 'iata_code': 'NCE' } }, + # {'names':[ + # {'name': 'San Francisco Apt'}, {'name': 'San Francisco Intl. Airport'}], + # 'cities': { 'city_details': { 'iata_code': 'SFO' } }, + # ]} + # + def interpretFromJSON(self, jsonFormattedResult): + parsedStruct = json.loads(jsonFormattedResult) + interpretedString = "" + for location in parsedStruct["locations"]: + interpretedString += location["iata_code"] + "-" + interpretedString += location["icao_code"] + "-" + interpretedString += location["geonames_id"] + " " + interpretedString += "(" + location["page_rank"] + "%) / " + interpretedString += location["cities"]["city_details"]["iata_code"] + ": " + interpretedString += location["lat"] + " " + interpretedString += location["lon"] + "; " + + # + return interpretedString + + ## + # Protobuf interpreter. The Protobuf structure contains a list with the + # main matches, along with their associated fields (weights, coordinates, + # etc). + def interpretFromProtobuf(self, protobufFormattedResult): + unmatchedKeywordString, interpretedString = "", "" + + # DEBUG + # print (f"DEBUG - Protobuf (array of bytes): {protobufFormattedResult}") + + # Protobuf + import google.protobuf.message + + try: + import Travel_pb2 + + except ImportError: + # + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The Travel Protobuf part of the " \ + "OpenTrep Python externsion cannot be properly initialized. " \ + "See https://pypi.org/project/opentrep/" + raise ImportError(err_msg) + + queryAnswer = Travel_pb2.QueryAnswer() + try: + queryAnswer.ParseFromString(protobufFormattedResult) + + except google.protobuf.message.DecodeError as err: + # + log_pfx = self.get_log_pfx() + print(f"{log_pfx} Error - Issue with the decoding. Will continue " \ + "though") + print(f"{log_pfx} Protobuf QueryAnswer object: {queryAnswer}") + + # List of recognised places + placeList = queryAnswer.place_list + + # DEBUG + if self.log_level >= 5: + print (f"DEBUG - Result: {placeList}") + + for place in placeList.place: + airport_code = place.tvl_code + interpretedString += airport_code.code + "-" + icao_code = place.icao_code + interpretedString += icao_code.code + "-" + geoname_id = place.geonames_id + interpretedString += str(geoname_id.id) + " " + page_rank = place.page_rank + interpretedString += "(" + str(page_rank.rank) + "%) / " + city_list = place.city_list.city + city = Travel_pb2.City() + for svd_city in city_list: + city = svd_city + interpretedString += str(city.code.code) + ": " + geo_point = place.coord + interpretedString += str(geo_point.latitude) + " " + interpretedString += str(geo_point.longitude) + "; " + + # List of un-matched keywords + unmatchedKeywords = queryAnswer.unmatched_keyword_list + + for keyword in unmatchedKeywords.word: + unmatchedKeywordString += keyword + + # + return unmatchedKeywordString, interpretedString + + def search(self, search_string=None, outputFormat=None): + """ + Search + + If no search string was supplied as arguments of the command-line, + ask the user for some + + Call the OpenTrep C++ library. + + The 'I' (Interpretation from JSON) output format is just an example + of how to use the output generated by the OpenTrep library. Hence, + that latter does not support that "output format". So, the raw JSON + format is required, and the JSON string will then be parsed and + interpreted by the jsonResultParser() method, just to show how it + works + """ + + # Check that the OpenTrep Python extension has been initialized + if not self._trep_lib: + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The OpenTrep Python extension has " \ + "not been initialized properly - OpenTrepLib: {self}" + raise OPTInitError(err_msg) + + if not outputFormat: + outputFormat = self.output_format + + if outputFormat not in self.output_available_formats: + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The given output format " \ + f"('{outputFormat}') is invalid. It should be one of " \ + f"{self.output_available_formats}." + raise OutputFormatError(err_msg) + + # If no search string was supplied as arguments of the command-line, + # ask the user for some + if not search_string: + # Ask for the user input + search_string = raw_input( + "Enter a search string, e.g., 'rio de janero sna francisco'" + ) + if search_string == "": + search_string = "nce sfo" + + # DEBUG + if self.log_level >= 4: + print(f"search_string: {search_string}") + + ## + # Call the OpenTrep C++ library. + # + opentrepOutputFormat = outputFormat + result = None + + # The 'I' (Interpretation from JSON) output format is just an example + # of how to use the output generated by the OpenTrep library. Hence, + # that latter does not support that "output format". So, the raw JSON + # format is required, and the JSON string will then be parsed and + # interpreted by the interpretFromJSON() method, just to show how it + # works + if opentrepOutputFormat == "I": + opentrepOutputFormat = "J" + + # + if opentrepOutputFormat != "P": + result = self._trep_lib.search(opentrepOutputFormat, search_string) + + # When the compact format is selected, the result string has to be + # parsed accordingly. + if outputFormat == "S": + parsedStruct = self.compactResultParser(result) + print("Compact format => recognised place (city/airport) codes:") + print(parsedStruct) + print("------------------") + + # When the full details have been requested, the result string is + # potentially big and complex, and is not aimed to be + # parsed. So, the result string is just displayed/dumped as is. + elif outputFormat == "F": + print("Raw result from the OpenTrep library:") + print(result) + print("------------------") + + # When the raw JSON format has been requested, no handling is necessary. + elif outputFormat == "J": + print("Raw (JSON) result from the OpenTrep library:") + print(result) + print("------------------") + + # The interpreted JSON format is an example of how to extract relevant + # information from the corresponding Python structure. That code can be + # copied/pasted by clients to the OpenTREP library. + elif outputFormat == "I": + interpretedString = self.interpretFromJSON(result) + print("JSON format => recognised place (city/airport) codes:") + print(interpretedString) + print("------------------") + + # The interpreted Protobuf format is an example of how to extract + # relevant information from the corresponding Python structure. + # That code can be copied/pasted by clients to the OpenTREP library. + elif outputFormat == "P": + result = self._trep_lib.searchToPB(search_string) + unmatchedKeywords, interpretedString = interpretFromProtobuf(result) + print("Protobuf format => recognised place (city/airport) codes:") + print(interpretedString) + print("Unmatched keywords:") + print(unmatchedKeywords) + print("------------------") + + def compactResultParser(self, resultString): + """ + Compact result parser. The result string contains the main matches, + separated by commas (','), along with their associated weights, given + as percentage numbers. For every main match: + + - Columns (':') separate potential extra matches (i.e., matches with the same + matching percentage). + - Dashes ('-') separate potential alternate matches (i.e., matches with lower + matching percentages). + + Samples of result string to be parsed: + + % python3 pyopentrep.py -f S nice sna francisco vancouver niznayou + 'nce/100,sfo/100-emb/98-jcc/97,yvr/100-cxh/83-xea/83-ydt/83;niznayou' + % python3 pyopentrep.py -f S fr + 'aur:avf:bae:bou:chr:cmf:cqf:csf:cvf:dij/100' + + >>> test_1 = 'nce/100,sfo/100-emb/98-jcc/97,yvr/100-cxh/83-xea/83-ydt/83;niznayou' + >>> compactResultParser(test_1) + ([(1.0, 'NCE'), (1.0, 'SFO'), (1.0, 'YVR')], 'niznayou') + + >>> test_2 = 'aur:avf:bae:bou:chr:cmf:cqf:csf:cvf:dij/100' + >>> compactResultParser(test_2) + ([(1.0, 'AUR')], '') + + >>> test_3 = ';eeee' + >>> compactResultParser(test_3) + ([], 'eeee') + """ + + # Strip out the unrecognised keywords + if ';' in resultString: + str_matches, unrecognized = resultString.split(';', 1) + else: + str_matches, unrecognized = resultString, '' + + if not str_matches: + return [], unrecognized + + codes = [] + + for alter_loc in str_matches.split(','): + + for extra_loc in alter_loc.split('-'): + + extra_loc, score = extra_loc.split('/', 1) + + for code in extra_loc.split(':'): + + codes.append((float(score) / 100.0, code.upper())) + + # We break because we only want to first + break + + # We break because we only want to first + break + + return codes, unrecognized + + def jsonResultParser(self, resultString): + ''' + JSON interpreter. The JSON structure contains a list with the main matches, + along with their associated fields (weights, coordinates, etc). + For every main match: + + - There is a potential list of extra matches (i.e., matches with the same + matching percentage). + - There is a potential list of alternate matches (i.e., matches with lower + matching percentages). + + Samples of result string to be parsed: + + - python3 pyopentrep.py -f J nice sna francisco + - {'locations':[ + {'names':[ + {'name': 'nice'}, {'name': 'nice/fr:cote d azur'}], + 'city_code': 'nce'}, + {'names':[ + {'name': 'san francisco'}, {'name': 'san francisco/ca/us:intl'}], + 'city_code': 'sfo', + 'alternates':[ + {'names':[ + {'name': 'san francisco emb'}, + {'name': 'san francisco/ca/us:embarkader'}], + 'city_code': 'sfo'}, + {'names':[ + {'name': 'san francisco jcc'}, + {'name': 'san francisco/ca/us:china hpt'}], + 'city_code': 'sfo'} + ]} + ]} + + - python3 pyopentrep.py -f J fr + - {'locations':[ + {'names':[ + {'name': 'aurillac'}, {'name': 'aurillac/fr'}], + 'extras':[ + {'names':[ + {'name': 'avoriaz'}, {'name': 'avoriaz/fr'}], + 'city_code': 'avf'}, + {'names':[ + {'name': 'barcelonnette'}, {'name': 'barcelonnette/fr'}], + 'city_code': 'bae'} + ]} + ]} + + >>> res = """{ "locations":[{ + ... "iata_code": "ORY", + ... "icao_code": "LFPO", + ... "city_code": "PAR", + ... "geonames_id": "2988500", + ... "lon": "2.359444", + ... "lat": "48.725278", + ... "page_rank": "23.53" + ... }, { + ... "iata_code": "CDG", + ... "icao_code": "LFPG", + ... "city_code": "PAR", + ... "geonames_id": "6269554", + ... "lon": "2.55", + ... "lat": "49.012779", + ... "page_rank": "64.70" + ... }] + ... }""" + >>> print(jsonResultParser(res)) + ORY-LFPO-2988500-23.53%-PAR-48.73-2.36; CDG-LFPG-6269554-64.70%-PAR-49.01-2.55 + ''' + + return '; '.join( + '-'.join([ + loc['iata_code'], + loc['icao_code'], + loc['geonames_id'], + '%.2f%%' % float(loc['page_rank']), + loc['cities']['city_details']['iata_code'], + '%.2f' % float(loc['lat']), + '%.2f' % float(loc['lon']) + ]) + for loc in json.loads(resultString)['locations'] + ) + + def mkdir_p(self, path): + """ + mkdir -p behavior. + """ + + pathlib.Path(path).mkdir(parents=True, exist_ok=True) + does_path_exist = os.path.isdir(path) + + if not does_path_exist: + log_pfx = self.get_log_pfx() + err_msg = f"{log_pfx} Error - The {path} directory structure " \ + f"cannot be created. It may come from an issue with permissions." + raise PathCreationError(err_msg) + diff --git a/OpenTrepWrapper/__init__.py b/OpenTrepWrapper/__init__.py new file mode 100644 index 0000000..6e2d793 --- /dev/null +++ b/OpenTrepWrapper/__init__.py @@ -0,0 +1,7 @@ +# +# Source: +# + +from .OpenTrepWrapper import OpenTrepLib +from .cli import main + diff --git a/OpenTrepWrapperMain.py b/OpenTrepWrapper/cli.py similarity index 58% rename from OpenTrepWrapperMain.py rename to OpenTrepWrapper/cli.py index 98de7cc..1a6bf57 100644 --- a/OpenTrepWrapperMain.py +++ b/OpenTrepWrapper/cli.py @@ -1,14 +1,10 @@ -#!/usr/bin/python # -*- coding: utf-8 -*- ''' This module is the OpenTrep binding main script. ''' -from OpenTrepWrapper import main_trep, index_trep -from OpenTrepWrapper import DEFAULT_LOG, DEFAULT_FMT, DEFAULT_DB - - +import OpenTrepWrapper def main(): ''' @@ -30,20 +26,20 @@ def main(): parser.add_argument('-f', '--format', help = '''Choose a different format. Must be either F, S, J, I. - Default is "%s"''' % DEFAULT_FMT, - default = DEFAULT_FMT + Default is "%s"''' % OpenTrepWrapper.DEFAULT_FMT, + default = OpenTrepWrapper.DEFAULT_FMT ) parser.add_argument('-x', '--xapiandb', help = '''Specify the xapian db location. - Default is "%s"''' % DEFAULT_DB, - default = DEFAULT_DB + Default is "%s"''' % OpenTrepWrapper.DEFAULT_DB, + default = OpenTrepWrapper.DEFAULT_DB ) parser.add_argument('-l', '--log', help = '''Specify a log file. - Default is "%s"''' % DEFAULT_LOG, - default = DEFAULT_LOG + Default is "%s"''' % OpenTrepWrapper.DEFAULT_LOG, + default = OpenTrepWrapper.DEFAULT_LOG ) parser.add_argument('-q', '--quiet', @@ -58,20 +54,21 @@ def main(): args = vars(parser.parse_args()) + ot = OpenTrepWrapper() + if args['index']: - index_trep(xapianDBPath=args['xapiandb'], - logFilePath=args['log'], - verbose=not(args['quiet'])) + ot.index_trep(xapianDBPath=args['xapiandb'], + logFilePath=args['log'], + verbose=not(args['quiet'])) exit() - main_trep(searchString=' '.join(args['keys']), - outputFormat=args['format'], - xapianDBPath=args['xapiandb'], - logFilePath=args['log'], - verbose=not(args['quiet'])) - + ot.main_trep(searchString=' '.join(args['keys']), + outputFormat=args['format'], + xapianDBPath=args['xapiandb'], + logFilePath=args['log'], + verbose=not(args['quiet'])) if __name__ == '__main__': diff --git a/OpenTrepWrapper/utilities.py b/OpenTrepWrapper/utilities.py new file mode 100644 index 0000000..e384369 --- /dev/null +++ b/OpenTrepWrapper/utilities.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# +# Source: https://github.com/trep/wrapper/tree/master/OpenTrepWrapper/utilities.py +# +# Authors: Alex Prengere, Denis Arnaud +# + +import json +import os +import errno + +# Default settings +DEFAULT_POR = '/tmp/opentraveldata/optd_por_public_all.csv' +DEFAULT_IDX = '/tmp/opentrep/xapian_traveldb' +DEFAULT_FMT = 'S' +DEFAULT_LOG = '/tmp/opentrep/opentrepwrapper.log' + + +def _test(): + ''' + Launching doctests. + ''' + import doctest + + opt = doctest.ELLIPSIS + + doctest.testmod(optionflags=opt) + + diff --git a/README.md b/README.md index 571bcf0..2bb4de3 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ OpenTrepWrapper # Overview [OpenTREP](https://github.com/trep/opentrep) aims at providing a clean API, -and the corresponding C++ implementation, for parsing travel-/transport-focused -requests. It powers the https://transport-search.org Web site (as well +its corresponding C++ implementation and Python extension, for parsing +travel-/transport-focused requests. It powers the +https://transport-search.org Web site (as well as its newer version, https://www2.transport-search.org). As part of the [OpenTREP releases](https://github.com/trep/opentrep/releases), @@ -53,7 +54,8 @@ $ pyenv install 3.8.5 && pyenv global 3.8.5 && \ * Clone this Git repository: ```bash -$ mkdir -p ~/dev/geo/trep && git clone https://github.com/trep/wrapper.git ~/dev/geo/trep-wrapper +$ mkdir -p ~/dev/geo/trep && \ + git clone https://github.com/trep/wrapper.git ~/dev/geo/trep-wrapper ``` * Install the Python virtual environment: @@ -164,10 +166,11 @@ $ export DYLD_LIBRARY_PATH=/usr/local/lib >>> myOPTD.downloadFilesIfNeeded() >>> myOPTD OpenTravelData: - Local IATA/ICAO POR file: /tmp/opentraveldata/optd_por_public_all.csv - Local UN/LOCODE POR file: /tmp/opentraveldata/optd_por_unlc.csv + Local IATA/ICAO POR file: /tmp/opentraveldata/optd_por_public_all.csv + Local UN/LOCODE POR file: /tmp/opentraveldata/optd_por_unlc.csv >>> myOPTD.fileSizes() -(44044195, 4888086) +(44079255, 4888752) +>>> ``` * Initialize the Xapian index with the `-i` option of `pyopentrep.py`, @@ -234,21 +237,77 @@ Python 3.8.5 (default, Jul 24 2020, 13:35:18) >>> ``` -* Import the module: +* Import the module and initialize the Python extension: ```python ->>> from OpenTrepWrapper import main_trep, index_trep ->>> from OpenTrepWrapper import DEFAULT_LOG, DEFAULT_FMT, DEFAULT_DB +>>> import OpenTrepWrapper +>>> otp = OpenTrepWrapper.OpenTrepLib() +>>> otp.init_cpp_extension(por_path=None, + xapian_index_path="/tmp/opentrep/xapian_traveldb", + sql_db_type="nodb", + sql_db_conn_str=None, + deployment_nb=0, + log_path="test_OpenTrepWrapper.log", + log_level=5) +>>> ``` * Index the OPTD data file: ```python ->>> index_trep (xapianDBPath = '/tmp/opentrep/xapian_traveldb', logFilePath = '/tmp/opentrep/opeentrep-indexer.log', verbose = False) +>>> otp.index() ``` -* Search: +* Search + + Short output format: ```python ->>> main_trep (searchString = 'nce sfo', outputFormat = 'S', xapianDBPath = '/tmp/opentrep/xapian_traveldb', logFilePath = '/tmp/opentrep/opeentrep-searcher.log', verbose = False) - ([(89.8466, 'NCE'), (357.45599999999996, 'SFO')], '') +>>> otp.search(search_string="nce sfo", outputFormat="S") +([(89.8466, 'NCE'), (357.45599999999996, 'SFO')], '') +------------------ +``` + + Full output format: +```python +>>> otp.search(search_string="nce sfo", outputFormat="F") +search_string: nce sfo +Raw result from the OpenTrep library: +1. NCE-A-6299418, 8.16788%, Nice Côte d'Azur International Airport, Nice Cote d'Azur International Airport, LFMN, , FRNCE, , 0, 1970-Jan-01, 2999-Dec-31, , NCE|2990440|Nice|Nice|FR|PAC, PAC, FR, , France, 427, France, EUR, NA, Europe, 43.6584, 7.21587, S, AIRP, 93, Provence-Alpes-Côte d'Azur, Provence-Alpes-Cote d'Azur, 06, Alpes-Maritimes, Alpes-Maritimes, 062, 06088, 0, 3, 5, Europe/Paris, 1, 2, 1, 2018-Dec-05, , https://en.wikipedia.org/wiki/Nice_C%C3%B4te_d%27Azur_Airport, 43.6627, 7.20787, nce, nce, 8984.66%, 0, 0 +2. SFO-C-5391959, 32.496%, San Francisco, San Francisco, , , USSFO, , 0, 1970-Jan-01, 2999-Dec-31, , SFO|5391959|San Francisco|San Francisco|US|CA, CA, US, , United States, 91, California, USD, NA, North America, 37.7749, -122.419, P, PPLA2, CA, California, California, 075, City and County of San Francisco, City and County of San Francisco, Z, , 864816, 16, 28, America/Los_Angeles, -8, -7, -8, 2019-Sep-05, SFO, https://en.wikipedia.org/wiki/San_Francisco, 0, 0, sfo, sfo, 35745.6%, 0, 0 + +------------------ +``` + + JSON output format: +```python +>>> otp.search(search_string="nce sfo", outputFormat="J") +search_string: nce sfo +Raw (JSON) result from the OpenTrep library: +{ + "locations": [ + { + "iata_code": "NCE", + "icao_code": "LFMN", + "geonames_id": "6299418", + "feature_class": "S", + "feature_code": "AIRP", + ... + }, + { + "iata_code": "SFO", + "icao_code": "", + "geonames_id": "5391959", + "feature_class": "P", + "feature_code": "PPLA2", + ... + } + ] +} + +------------------ +``` + + Interpreted format from JSON: +```python +>>> otp.search(search_string="nce sfo", outputFormat="I") +search_string: nce sfo +JSON format => recognised place (city/airport) codes: +NCE-LFMN-6299418 (8.1678768123135352%) / NCE: 43.658411000000001 7.2158720000000001; SFO--5391959 (32.496021550940505%) / SFO: 37.774929999999998 -122.41942; +------------------ ``` * End the Python session: @@ -260,19 +319,22 @@ Python 3.8.5 (default, Jul 24 2020, 13:35:18) ```bash $ ASAN_OPTIONS=detect_container_overflow=0 \ DYLD_INSERT_LIBRARIES=/Library/Developer/CommandLineTools/usr/lib/clang/11.0.0/lib/darwin/libclang_rt.asan_osx_dynamic.dylib \ - /usr/local/Cellar/python\@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/Resources/Python.app/Contents/MacOS/Python test.py -...... ----------------------------------------------------------------------- -Ran 6 tests in 2.832s - -OK + /usr/local/Cellar/python\@3.8/3.8.5/Frameworks/Python.framework/Versions/3.8/Resources/Python.app/Contents/MacOS/Python -mpytest test_OpenTrepWrapper.py +====================== test session starts ===================== +platform darwin -- Python 3.8.5, pytest-5.4.3, py-1.8.1, pluggy-0.13.1 +rootdir: /Users/darnaud/dev/geo/trep-wrapper +plugins: dash-1.12.0 +collected 3 items +test_OpenTrepWrapper.py ... [100%] + +====================== 3 passed in 1.35s ======================= ``` # Release OpenTrepWrapper to PyPi * Build the Python artifacts for OpenTrepWrapper: ```bash -$ rm -rf dist && mkdir dist -$ pipenv run python setup.py sdist bdist_wheel bdist_egg +$ rm -rf dist build */*.egg-info *.egg-info .tox MANIFEST +$ pipenv run python setup.py sdist bdist_wheel $ ls -lFh dist total 56 -rw-r--r-- 1 user staff 7.7K Mar 2 11:14 OpenTrepWrapper-0.7.7.post1-py3-none-any.whl diff --git a/setup.py b/setup.py index 854d129..aacbff2 100644 --- a/setup.py +++ b/setup.py @@ -1,36 +1,70 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -''' -Main installation script. -''' +import io import os -from setuptools import setup +import glob +import setuptools -def read(fname): - return open(os.path.join(os.path.dirname(__file__), fname)).read() +def read(*names, **kwargs): + with io.open( + os.path.join(os.path.dirname(__file__), *names), + encoding=kwargs.get('encoding', 'utf8') + ) as fh: + return fh.read() -setup( +setuptools.setup( name = 'OpenTrepWrapper', - version = '0.7.7.post1', - author = 'Alex Prengere', - author_email = 'alex.prengere@gmail.com', + version = '0.7.7.post2', + author = 'Denis Arnaud', + author_email = 'denis.arnaud_fedora@m4x.org', url = 'https://github.com/trep/wrapper', description = 'A Python wrapper module for OpenTrep', long_description = read('README.md'), long_description_content_type = 'text/markdown', + packages=setuptools.find_packages(), + include_package_data=True, + classifiers=[ + # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Operating System :: Unix', + 'Operating System :: POSIX', + 'Operating System :: Microsoft :: Windows', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: Implementation :: CPython', + # uncomment if you test on these interpreters: + # 'Programming Language :: Python :: Implementation :: PyPy', + # 'Programming Language :: Python :: Implementation :: IronPython', + # 'Programming Language :: Python :: Implementation :: Jython', + # 'Programming Language :: Python :: Implementation :: Stackless', + 'Topic :: Utilities', + ], + project_urls={ + 'Documentation': 'https://opentrep.readthedocs.io/en/latest/', + 'Changelog': 'https://opentrep.readthedocs.io/en/latest/opentrep.html', + 'Issue Tracker': 'https://github.com/trep/wrapper/issues', + }, + keywords=[ + 'data', 'trep', 'request', 'parser', 'travel', 'transport', + 'search', 'wrapper', 'optd', 'opentraveldata', 'opentrep' + ], + python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', + install_requires=[ + # 'some--package', + ], + extras_require={ + # eg: + # 'rst': ['docutils>=0.11'], + # ':python_version=="2.6"': ['argparse'], + }, entry_points = { 'console_scripts' : [ - 'OpenTrep = OpenTrepWrapperMain:main', + 'OpenTrep = OpenTrepWrapper.cli:main', ] }, - py_modules = [ - 'OpenTrepWrapper', - 'OpenTrepWrapperMain' - ], - install_requires = [ - ], - #sanitize_lib = ['-lasan'] if cc == 'gcc' and not is_macos else [], ) diff --git a/test_OpenTrepWrapper.py b/test_OpenTrepWrapper.py new file mode 100644 index 0000000..fbf5c08 --- /dev/null +++ b/test_OpenTrepWrapper.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# +# Source: https://github.com/trep/wrapper/tree/master/test_OpenTrepWrapper.py +# +# Authors: Denis Arnaud, Alex Prengere +# + +import unittest +import OpenTrepWrapper + + +class OpenTrepWrapperTest(unittest.TestCase): + + def test_initOpenTrepLib(self): + # DEBUG + print("[OTP][test_initOpenTrepLib] - Initializing OpenTrepLib...") + + otp = OpenTrepWrapper.OpenTrepLib() + self.assertIsNotNone(otp) + + # + otp.init_cpp_extension() + + def test_get_paths(self): + # DEBUG + print("[OTP][test_get_paths] - Get the file-paths...") + + otp = OpenTrepWrapper.OpenTrepLib() + self.assertIsNotNone(otp) + + # + otp.init_cpp_extension(por_path=None, + xapian_index_path="/tmp/opentrep/xapian_traveldb", + sql_db_type="nodb", + sql_db_conn_str=None, + deployment_nb=0, + log_path="test_OpenTrepWrapper.log", + log_level=5) + + file_path_list = otp.getPaths() + + # DEBUG + print(f"[OTP][test_get_paths] - File-path list: {file_path_list}") + self.assertEqual(file_path_list, None) + + def test_index_test_por_file(self): + # DEBUG + print("[OTP][test_index_test_por_file] - Index with the test file...") + + otp = OpenTrepWrapper.OpenTrepLib() + self.assertIsNotNone(otp) + + # + otp.init_cpp_extension(por_path=None, + xapian_index_path="/tmp/opentrep/xapian_traveldb", + sql_db_type="nodb", + sql_db_conn_str=None, + deployment_nb=0, + log_path="test_OpenTrepWrapper.log", + log_level=5) + + # Index the POR test file + otp.index() + + # Search + otp.search(search_string="nce sfo", outputFormat="F") + diff --git a/test.py b/test_doctest.py similarity index 76% rename from test.py rename to test_doctest.py index 5967504..38762c4 100644 --- a/test.py +++ b/test_doctest.py @@ -1,5 +1,9 @@ -#!/usr/bin/python # -*- coding: utf-8 -*- +# +# Source: https://github.com/trep/wrapper/tree/master/test_doctest.py +# +# Authors: Alex Prengere, Denis Arnaud +# import unittest import doctest