"""
Configuration parser module.
Programmed by: Dante Signal31
email: dante.signal31@gmail.com
"""
import http.client as http
import os
import pickle
import urllib.parse as urlparse
[docs]def get_real_path(CONFIG_FILE):
this_module_path = os.path.realpath(__file__)
this_module_folder = os.path.dirname(this_module_path)
parent_folder, _ = os.path.split(this_module_folder)
config_file_path = os.path.join(parent_folder, CONFIG_FILE)
return config_file_path
CONFIG_FILE = "etc/geolocate.conf"
CONFIG_FILE_PATH = get_real_path(CONFIG_FILE)
DEFAULT_USER_ID = ""
DEFAULT_LICENSE_KEY = ""
# TODO: For production I have to uncomment real url.
# Only for tests I have to comment real download url. MaxMind has a rate limit
# per day. If you exceed that limit you are forbidden for 24 hours to download
# their database.
DEFAULT_DATABASE_DOWNLOAD_URL = "http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz"
# TODO: For production remove next fake url, it's only for tests.
# DEFAULT_DATABASE_DOWNLOAD_URL = "http://localhost:2014/GeoLite2-City.mmdb.gz"
# GeoLite2 databases are updated on the first Tuesday of each month, so 35 days
# of update interval should be fine.
DEFAULT_UPDATE_INTERVAL = 35
DEFAULT_LOCAL_DATABASE_FOLDER = get_real_path("local_database/")
DEFAULT_LOCAL_DATABASE_NAME = "GeoLite2-City.mmdb"
# Remember add new locators here or locate won't use them.
DEFAULT_LOCATORS_PREFERENCE = ["geoip2_webservice", "geoip2_local"]
[docs]class Configuration(object):
# I've discovered Maxmind website blocks clients who exceeds a connection
# threshold. If we make a connection each time we run geolocate, in order
# to check that configured URL is OK, we can end in Maxmind blacklist. So,
# we have to minimize connections. Check only when configuration is updated
# is a way, but then we have to control how users update config. Best way
# is limit users to change configuration only through executable's
# parameters. If we let them change manually configuration file we should
# check it each time we run the program. We'd better close configuration
# file through serialization and check URL only when user makes program
# change configuration.
""" Class to encapsulate configuration needed to connect to Geolite2
webservices or downloaded local database. This class also validates
parameters read from config files to overcome user typos.
"""
[docs] def __init__(self, user_id=DEFAULT_USER_ID,
license_key=DEFAULT_LICENSE_KEY,
download_url=DEFAULT_DATABASE_DOWNLOAD_URL,
update_interval=DEFAULT_UPDATE_INTERVAL,
local_database_folder=DEFAULT_LOCAL_DATABASE_FOLDER,
local_database_name=DEFAULT_LOCAL_DATABASE_NAME,
locators_preference=DEFAULT_LOCATORS_PREFERENCE):
self._webservice = {"user_id": user_id,
"license_key": license_key}
self._local_database = {"download_url": download_url,
"update_interval": update_interval,
"local_database_folder": local_database_folder,
"local_database_name": local_database_name}
self._locators_preference = locators_preference
@property
def user_id(self):
return self._webservice["user_id"]
@user_id.setter
def user_id(self, user_id):
_validate_value("user_id", user_id)
self._webservice["user_id"] = user_id
@property
def license_key(self):
return self._webservice["license_key"]
@license_key.setter
def license_key(self, license_key):
_validate_value("license_key", license_key)
self._webservice["license_key"] = license_key
@property
def download_url(self):
return self._local_database["download_url"]
@download_url.setter
def download_url(self, url):
_validate_url("download_url", url)
self._local_database["download_url"] = url
@property
def update_interval(self):
return self._local_database["update_interval"]
@update_interval.setter
def update_interval(self, update_interval_in_days):
interval_integer = _validate_integer("update_interval",
update_interval_in_days)
self._local_database["update_interval"] = interval_integer
@property
def local_database_folder(self):
return self._local_database["local_database_folder"]
@local_database_folder.setter
def local_database_folder(self, folder_path):
database_folder_path = _get_folder_path(folder_path)
_validate_folder("local_database_folder", database_folder_path)
self._local_database["local_database_folder"] = database_folder_path
@property
def local_database_name(self):
return self._local_database["local_database_name"]
@local_database_name.setter
def local_database_name(self, database_name):
# At first sight every database name should be OK, but I'll leave this
# as a property in case I have an idea about a possible check.
self._local_database["local_database_name"] = database_name
@property
def local_database_path(self):
path = os.path.join(self.local_database_folder,
self.local_database_name)
return path
@property
def locators_preference(self):
"""
:return: Enabled locators for this GeoIPDatabase ordered by preference.
:rtype: list
"""
return self._locators_preference
@locators_preference.setter
def locators_preference(self, new_locator_list):
if _unknown_locators(new_locator_list):
unknown_locators = _get_unknown_locators(new_locator_list)
raise UnknownLocators(unknown_locators)
else:
self._locators_preference = new_locator_list
def __eq__(self, other):
for _property, value in vars(self).items():
if getattr(other, _property) != value:
return False
return True
[docs] def reset_locators_preference(self):
""" Reset locators preference to default order.
:return: None
"""
self._locators_preference = DEFAULT_LOCATORS_PREFERENCE
@property
def disabled_locators(self):
""" Locators registered as default one but not enabled in this
GeoIPDatabase.
:return: Disabled locators in this GeoIPDatabase.
:rtype: set
"""
default_locators_set = set(DEFAULT_LOCATORS_PREFERENCE)
enabled_locators_set = set(self.locators_preference)
disabled_locators_set = default_locators_set - enabled_locators_set
return disabled_locators_set
def _validate_value(parameter, value):
# TODO: Add more checks to detect invalid values when you know MaxMind's
# conditions for user ids.
if _text_has_spaces(value) or value == "":
raise ParameterNotValid(value, parameter,
" ". join([parameter, "cannot have spaces."]))
def _validate_url(parameter, url):
"""
Check if a URL exists without downloading the whole file.
We only check the URL header.
:param parameter: Attribute that is being validated.
:type parameter: str
:param url: HTTP url to check its existence.
:type url: str
:return: None
:raise: ParameterNotValid
"""
# see also http://stackoverflow.com/questions/2924422
good_codes = [http.OK, http.FOUND, http.MOVED_PERMANENTLY]
try:
if _get_server_status_code(url) in good_codes:
return # Validation succeeded.
else:
raise Exception # Let outer except raise one only exception.
except:
raise ParameterNotValid(url, parameter, "Cannot connect to given "
"URL.")
def _validate_folder(parameter, path):
"""
:param parameter: Attribute that is being validated.
:type parameter: str
:param path: Path to folder being checked.
:type path: str
:return: None
:raise: ParameterNotValid
"""
if not os.path.exists(path):
raise ParameterNotValid(path, parameter, "Folder does not exists.")
def _get_server_status_code(url):
"""
Download just the header of a URL and return the server's status code.
:param url: HTTP url to check its existence.
:type url: str
:return: One of the connection status from http.client.
:rtype: int
:raise: Any of the exceptions from http.client built-in module.
"""
# http://stackoverflow.com/questions/1140661
host, path = urlparse.urlparse(url)[1:3] # elems [1] and [2]
conn = http.HTTPConnection(host)
conn.request('HEAD', path)
return conn.getresponse().status
def _validate_integer(parameter, value):
"""
:param parameter: Attribute that is being validated.
:type parameter: str
:param value: Value integer o string.
:type value: int or str
:return: Value converted to an integer.
:rtype: int
"""
try:
integer_value = int(value)
if integer_value <= 0:
raise ValueError
except ValueError:
raise ParameterNotValid(value, parameter, "Cannot convert to int.")
return integer_value
def _text_has_spaces(text):
"""
:param text:
:type text: str
:return: True if text has any space or false otherwise.
:rtype: bool
"""
words = text.split(" ")
if len(words) > 1:
return True
else:
return False
[docs]def load_configuration():
""" Read configuration file and populate with its data a
config.Configuration instance.
:return: Configuration instance populated with configuration file data.
:rtype: config.Configuration
"""
try:
configuration = _read_config_file()
except ConfigNotFound:
_create_default_config_file()
configuration = load_configuration()
return configuration
def _read_config_file():
""" Load all configuration parameters set in config file.
:return: Configuration instance populated with configuration file data.
:rtype: config.Configuration
:raise: config.ConfigNotFound
"""
try:
with open(CONFIG_FILE_PATH, "rb") as config_file:
configuration = pickle.load(config_file)
except FileNotFoundError:
raise ConfigNotFound()
return configuration
def _create_default_config_file():
""" Create a default configuration file.
:return: None
"""
default_configuration = Configuration()
save_configuration(default_configuration)
[docs]def save_configuration(configuration):
""" Write Configuration object in config file.
:param configuration: Configuration to be saved.
:type configuration: config.Configuration
:return: None
"""
with open(CONFIG_FILE_PATH, "wb") as config_file:
pickle.dump(configuration, config_file, pickle.HIGHEST_PROTOCOL)
def _get_folder_path(path):
""" If path is relative, get absolute path of current working directory
suffixed by path. If path is absolute, just return it.
:param path: Path to get absolute form.
:type path: str
:return: Absolute path.
:rtype: str
"""
absolute_directory = None
if path.startswith("/"):
absolute_directory = path
else:
current_working_directory = os.getcwd()
absolute_directory = "{0}/{1}".format(current_working_directory,
path)
return absolute_directory
def _unknown_locators(locator_list):
""" Detects if any locator in provided list is not registered as a valid one.
Enabled locators are registered in DEFAULT_LOCATORS_PREFERENCE constant.
Locators have to be one of them to be declared valid.
:param locator_list: String list with locator names.
:type locator_list: list
:return: True if any locator in list is not within default locator list, else False.
:rtype: bool
"""
locator_set = set(locator_list)
default_locator_set = set(DEFAULT_LOCATORS_PREFERENCE)
if locator_set <= default_locator_set:
return False
else:
return True
def _get_unknown_locators(locator_list):
"""
:param locator_list: String list with locator names.
:type locator_list: list
:return: Set with unknown locators detected.
:rtype: set
"""
locator_set = set(locator_list)
default_locator_set = set(DEFAULT_LOCATORS_PREFERENCE)
return locator_set - default_locator_set
[docs]class ConfigNotFound(Exception):
""" Launched when config file is not where is supposed to be."""
[docs] def __init__(self):
message = "Configuration file is not at it's default " \
"location: {0}".format(CONFIG_FILE_PATH)
Exception.__init__(self, message)
[docs]class ParameterNotValid(Exception):
""" Launched when user_id validation fails."""
[docs] def __init__(self, provided_value, parameter, message):
self.provided_value = provided_value
self.parameter = parameter
parameter_message = "There is a problem with parameter {0}, you " \
"gave {1} as value.\n " \
"Problem is: \n".format(parameter, provided_value)
final_message = "".join([parameter_message, message])
Exception.__init__(self, final_message)
[docs]class UnknownLocators(Exception):
""" Raised when an still not implemented location is referenced in any
operation.
"""
[docs] def __init__(self, unknown_locators):
unknown_locators_text = " ".join(unknown_locators)
self.message = " ".join(["You tried to use not implemented locators:",
unknown_locators_text])
Exception.__init__(self, self.message)
[docs]class OpenConfigurationToUpdate(object):
""" Context manager to get a configuration file and save it automatically.
"""
[docs] def __init__(self):
self.configuration = load_configuration()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
save_configuration(self.configuration)
if exc_type is None:
return True
else:
return False