from __future__ import annotations import hashlib import importlib.util import json import os import re import ssl import sys import urllib.error import urllib.parse import urllib.request from typing import Optional, Dict, Union, TYPE_CHECKING, Any from types import ModuleType # https://stackoverflow.com/a/39757388/929999 if TYPE_CHECKING: from .installer import Installer _: Any from .general import multisplit from .networking import list_interfaces from .storage import storage from .exceptions import ProfileNotFound def grab_url_data(path :str) -> str: safe_path = path[: path.find(':') + 1] + ''.join([item if item in ('/', '?', '=', '&') else urllib.parse.quote(item) for item in multisplit(path[path.find(':') + 1:], ('/', '?', '=', '&'))]) ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE response = urllib.request.urlopen(safe_path, context=ssl_context) return response.read() # bytes? def is_desktop_profile(profile :str) -> bool: if str(profile) == 'Profile(desktop)': return True desktop_profile = Profile(None, "desktop") with open(desktop_profile.path, 'r') as source: source_data = source.read() if '__name__' in source_data and '__supported__' in source_data: with desktop_profile.load_instructions(namespace=f"{desktop_profile.namespace}.py") as imported: if hasattr(imported, '__supported__'): desktop_profiles = imported.__supported__ return str(profile) in [f"Profile({s})" for s in desktop_profiles] return False def list_profiles( filter_irrelevant_macs :bool = True, subpath :str = '', filter_top_level_profiles :bool = False ) -> Dict[str, Dict[str, Union[str, bool]]]: # TODO: Grab from github page as well, not just local static files if filter_irrelevant_macs: local_macs = list_interfaces() cache = {} # Grab all local profiles found in PROFILE_PATH for PATH_ITEM in storage['PROFILE_PATH']: for root, folders, files in os.walk(os.path.abspath(os.path.expanduser(PATH_ITEM + subpath))): for file in files: if file == '__init__.py': continue if os.path.splitext(file)[1] == '.py': tailored = False if len(mac := re.findall('(([a-zA-z0-9]{2}[-:]){5}([a-zA-z0-9]{2}))', file)): if filter_irrelevant_macs and mac[0][0].lower() not in local_macs: continue tailored = True description = '' with open(os.path.join(root, file), 'r') as fh: first_line = fh.readline() if len(first_line) and first_line[0] == '#': description = first_line[1:].strip() cache[file[:-3]] = {'path': os.path.join(root, file), 'description': description, 'tailored': tailored} break # Grab profiles from upstream URL if storage['PROFILE_DB']: profiles_url = os.path.join(storage["UPSTREAM_URL"] + subpath, storage['PROFILE_DB']) try: profile_list = json.loads(grab_url_data(profiles_url)) except urllib.error.HTTPError as err: print(_('Error: Listing profiles on URL "{}" resulted in:').format(profiles_url), err) return cache except json.decoder.JSONDecodeError as err: print(_('Error: Could not decode "{}" result as JSON:').format(profiles_url), err) return cache for profile in profile_list: if os.path.splitext(profile)[1] == '.py': tailored = False if len(mac := re.findall('(([a-zA-z0-9]{2}[-:]){5}([a-zA-z0-9]{2}))', profile)): if filter_irrelevant_macs and mac[0][0].lower() not in local_macs: continue tailored = True cache[profile[:-3]] = {'path': os.path.join(storage["UPSTREAM_URL"] + subpath, profile), 'description': profile_list[profile], 'tailored': tailored} if filter_top_level_profiles: for profile in list(cache.keys()): if Profile(None, profile).is_top_level_profile() is False: del cache[profile] return cache class Script: def __init__(self, profile :str, installer :Optional[Installer] = None): """ :param profile: A string representing either a boundled profile, a local python file or a remote path (URL) to a python script-profile. Three examples: * profile: https://archlinux.org/some_profile.py * profile: desktop * profile: /path/to/profile.py """ self.profile = profile self.installer = installer # TODO: Appears not to be used anymore? self.converted_path = None self.spec = None self.examples = {} self.namespace = os.path.splitext(os.path.basename(self.path))[0] self.original_namespace = self.namespace def __enter__(self, *args :str, **kwargs :str) -> ModuleType: self.execute() return sys.modules[self.namespace] def __exit__(self, *args :str, **kwargs :str) -> None: # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager if len(args) >= 2 and args[1]: raise args[1] if self.original_namespace: self.namespace = self.original_namespace def localize_path(self, profile_path :str) -> str: if (url := urllib.parse.urlparse(profile_path)).scheme and url.scheme in ('https', 'http'): if not self.converted_path: self.converted_path = f"/tmp/{os.path.basename(self.profile).replace('.py', '')}_{hashlib.md5(os.urandom(12)).hexdigest()}.py" with open(self.converted_path, "w") as temp_file: temp_file.write(urllib.request.urlopen(url.geturl()).read().decode('utf-8')) return self.converted_path else: return profile_path @property def path(self) -> str: parsed_url = urllib.parse.urlparse(self.profile) # The Profile was not a direct match on a remote URL if not parsed_url.scheme: # Try to locate all local or known URL's if not self.examples: self.examples = list_profiles() if f"{self.profile}" in self.examples: return self.localize_path(self.examples[self.profile]['path']) # TODO: Redundant, the below block shouldn't be needed as profiles are stripped of their .py, but just in case for now: elif f"{self.profile}.py" in self.examples: return self.localize_path(self.examples[f"{self.profile}.py"]['path']) # Path was not found in any known examples, check if it's an absolute path if os.path.isfile(self.profile): return self.profile raise ProfileNotFound(f"File {self.profile} does not exist in {storage['PROFILE_PATH']}") elif parsed_url.scheme in ('https', 'http'): return self.localize_path(self.profile) else: raise ProfileNotFound(f"Cannot handle scheme {parsed_url.scheme}") def load_instructions(self, namespace :Optional[str] = None) -> 'Script': if namespace: self.namespace = namespace self.spec = importlib.util.spec_from_file_location(self.namespace, self.path) imported = importlib.util.module_from_spec(self.spec) sys.modules[self.namespace] = imported return self def execute(self) -> ModuleType: if self.namespace not in sys.modules or self.spec is None: self.load_instructions() self.spec.loader.exec_module(sys.modules[self.namespace]) return sys.modules[self.namespace] class Profile(Script): def __init__(self, installer :Optional[Installer], path :str): super(Profile, self).__init__(path, installer) def __dump__(self, *args :str, **kwargs :str) -> Dict[str, str]: return {'path': self.path} def __repr__(self, *args :str, **kwargs :str) -> str: return f'Profile({os.path.basename(self.profile)})' @property def name(self) -> str: return os.path.basename(self.profile) @property def is_desktop_profile(self) -> bool: return is_desktop_profile(repr(self)) def install(self) -> ModuleType: # Before installing, revert any temporary changes to the namespace. # This ensures that the namespace during installation is the original initiation namespace. # (For instance awesome instead of aweosme.py or app-awesome.py) self.namespace = self.original_namespace return self.execute() def has_prep_function(self) -> bool: with open(self.path, 'r') as source: source_data = source.read() # Some crude safety checks, make sure the imported profile has # a __name__ check and if so, check if it's got a _prep_function() # we can call to ask for more user input. # # If the requirements are met, import with .py in the namespace to not # trigger a traditional: # if __name__ == 'moduleName' if '__name__' in source_data and '_prep_function' in source_data: with self.load_instructions(namespace=f"{self.namespace}.py") as imported: if hasattr(imported, '_prep_function'): return True return False def has_post_install(self) -> bool: with open(self.path, 'r') as source: source_data = source.read() # Some crude safety checks, make sure the imported profile has # a __name__ check and if so, check if it's got a _prep_function() # we can call to ask for more user input. # # If the requirements are met, import with .py in the namespace to not # trigger a traditional: # if __name__ == 'moduleName' if '__name__' in source_data and '_post_install' in source_data: with self.load_instructions(namespace=f"{self.namespace}.py") as imported: if hasattr(imported, '_post_install'): return True def is_top_level_profile(self) -> bool: with open(self.path, 'r') as source: source_data = source.read() if '__name__' in source_data and 'is_top_level_profile' in source_data: with self.load_instructions(namespace=f"{self.namespace}.py") as imported: if hasattr(imported, 'is_top_level_profile'): return imported.is_top_level_profile # Default to True if nothing is specified, # since developers like less code - omitting it should assume they want to present it. return True def get_profile_description(self) -> str: with open(self.path, 'r') as source: source_data = source.read() if '__description__' in source_data: with self.load_instructions(namespace=f"{self.namespace}.py") as imported: if hasattr(imported, '__description__'): return imported.__description__ # Default to this string if the profile does not have a description. return "This profile does not have the __description__ attribute set." @property def packages(self) -> Optional[list]: """ Returns a list of packages baked into the profile definition. If no package definition has been done, .packages() will return None. """ with open(self.path, 'r') as source: source_data = source.read() # Some crude safety checks, make sure the imported profile has # a __name__ check before importing. # # If the requirements are met, import with .py in the namespace to not # trigger a traditional: # if __name__ == 'moduleName' if '__name__' in source_data and '__packages__' in source_data: with self.load_instructions(namespace=f"{self.namespace}.py") as imported: if hasattr(imported, '__packages__'): return imported.__packages__ return None class Application(Profile): def __repr__(self, *args :str, **kwargs :str): return f'Application({os.path.basename(self.profile)})' @property def path(self) -> str: parsed_url = urllib.parse.urlparse(self.profile) # The Profile was not a direct match on a remote URL if not parsed_url.scheme: # Try to locate all local or known URL's if not self.examples: self.examples = list_profiles(subpath='/applications') if f"{self.profile}" in self.examples: return self.localize_path(self.examples[self.profile]['path']) # TODO: Redundant, the below block shouldn't be needed as profiles are stripped of their .py, but just in case for now: elif f"{self.profile}.py" in self.examples: return self.localize_path(self.examples[f"{self.profile}.py"]['path']) # Path was not found in any known examples, check if it's an absolute path if os.path.isfile(self.profile): return os.path.basename(self.profile) raise ProfileNotFound(f"Application file {self.profile} does not exist in {storage['PROFILE_PATH']}") elif parsed_url.scheme in ('https', 'http'): return self.localize_path(self.profile) else: raise ProfileNotFound(f"Application cannot handle scheme {parsed_url.scheme}") def install(self) -> ModuleType: # Before installing, revert any temporary changes to the namespace. # This ensures that the namespace during installation is the original initiation namespace. # (For instance awesome instead of aweosme.py or app-awesome.py) self.namespace = self.original_namespace return self.execute()