#!/usr/bin/python3 import subprocess import sys import argparse import os import json import platform import re import shutil from datetime import datetime def get_git_revision_hash(path=None) -> str: if path: # git -C # Runs the command as if git was started in instead of the current working directory. # return subprocess.check_output(['git', '-C', path, 'rev-parse', 'HEAD']).decode('ascii').strip() stdout = subprocess.run(['git', '-C', path, 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip() return stdout else: # return subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('ascii').strip() stdout = subprocess.run(['git', 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip() return stdout class DependencyType: local = 'local' git = 'git' class Module: # raw - это прочитанный json в виде словаря def __init__(self, raw=None): self.cmake = {'inc': [], 'src': [], 'inc_dirs': [], 'srcs': [], 'libs': [], 'uis': []} self.dep = [] # список из объектов Dependency self.raw = raw if raw: if 'cmake' in raw: if 'inc_dirs' in raw['cmake']: self.cmake['inc_dirs'] = raw['cmake']['inc_dirs'] if 'srcs' in raw['cmake']: self.cmake['srcs'] = raw['cmake']['srcs'] if 'libs' in raw['cmake']: self.cmake['libs'] = raw['cmake']['libs'] if 'uis' in raw['cmake']: self.cmake['uis'] = raw['cmake']['uis'] if 'dep' in raw: self.dep = [Dependency(rawDep) for rawDep in raw['dep']] def __str__(self): return str(self.cmake) def __repr__(self): return self.__str__() def __eq__(self, other): if not isinstance(other, type(self)): return False return self.cmake == other.cmake and self.dep == other.dep class Dependency: # raw - это словарь def __init__(self, raw): if raw: if raw['type'] == DependencyType.local: self.type = DependencyType.local self.dir = raw.get('dir', '') self.os = raw.get('os', '') elif raw['type'] == DependencyType.git: self.type = DependencyType.git self.provider = raw.get('provider', '') self.dir = raw.get('dir', '') self.repo = raw.get('repo', '') self.commit = raw.get('commit', '') self.os = raw.get('os', '') self.project = raw.get('project', 'false') else: raise ValueError('Unknown dependency ' + str(raw['type'])) def __eq__(self, other): if self.type != other.type: return False if self.type == DependencyType.local: return self.dir == other.dir elif self.type == DependencyType.git: return self.repo == other.repo else: raise ValueError('Unknown dependency' + str(self.type)) def __str__(self): if self.type == DependencyType.local: return 'local ' + str(self.dir) elif self.type == DependencyType.git: return 'git ' + str(self.repo) else: raise ValueError('Unknown dependency' + str(self.type)) class ModularProject: # получить домашнюю директорию и приклеить к ней путь к modular_builder/config.json userConfigFile = os.path.join(os.path.expanduser('~'), '.modular_builder', 'config.json') def __init__(self): self.clear() self.initDir() self.loadConfigs() def clear(self): self.loadedModules = [] self.rules = {} def initDir(self): if not os.path.exists('MODULES'): print('No MODULES folder, creating...') os.mkdir('MODULES') print('Done') def loadConfigs(self): if os.path.exists(self.userConfigFile): print('Has config file at ' + str(self.userConfigFile) + ', loading...') self.loadConfig(self.userConfigFile) print('Done') else: print('No config file at ' + str(self.userConfigFile)) # в config.json лежит примерно это: # { # "git_providers:" { # "rospron_modules": "http"//git...blabla.../rosprom_modules" # } # } def loadConfig(self, config_path): config = json.load(open(config_path, encoding='utf-8')) self.gitProviders = config['git_providers'] def loadJsonAtPath(self, path): # path - путь до modular.json print('loading json', path) try: with open(path, encoding='utf-8') as f: result = Module(json.load(f)) return result except json.JSONDecodeError: print('Error while reading json file at path:', path, file=sys.stderr) print('Try fixing JSON file and run modular.py again', file=sys.stderr) raise json.JSONDecodeError def downloadableModulePath(self, dependency): if dependency.type == DependencyType.git: if dependency.project == 'true': return dependency.repo else: return os.path.join('MODULES', dependency.repo) raise ValueError('UnknownDependency') def isGitModuleCloned(self, dependency): return os.path.exists(self.downloadableModulePath(dependency)) def isModuleSystemAppropriate(self, dependency): if not dependency.os: # если ОС не указана для модуля, то грузим его всегда return True elif dependency.os.lower() == 'windows': print('Module only for Windows') return platform.system().lower() == 'windows' elif dependency.os.lower() == 'linux': print('Module only for Linux') return platform.system().lower() == 'linux' else: print('Unknown OS:', dependency.os) print('Dependency will be uploaded anyway') return True def cloneGitDependency(self, dependency): path = self.downloadableModulePath(dependency) clone_cmd = 'git clone ' + str(self.gitProviders[dependency.provider]) + '/' + str(dependency.repo) + ' ' + str( path) print('Clone with', clone_cmd) os.system(clone_cmd) # выполняет любые поддерживаемые команды в командной строке if dependency.commit: checkout_cmd = f'git -C {path} checkout {dependency.commit}' # git -C # Runs the command as if git was started in instead of the current working directory. print(f'Checkout with {checkout_cmd}') os.system(checkout_cmd) # выполняет любые поддерживаемые команды в командной строке def loadDependency(self, dependency): if self.checkDependencyCollision(dependency): return [] print('\nLoading module', dependency) if dependency.type == DependencyType.local: return self.loadDependencyFromLocal(dependency) elif dependency.type == DependencyType.git: return self.loadDependencyFromGit(dependency) else: raise ValueError('Unknown module') def loadDependencyFromLocal(self, dependency): if self.isModuleSystemAppropriate(dependency): return self.loadDependencyFromDir(dependency.dir) def loadDependencyFromGit(self, dependency): if self.isModuleSystemAppropriate(dependency): if not self.isGitModuleCloned(dependency): self.cloneGitDependency(dependency) return self.loadDependencyFromDir(self.downloadableModulePath(dependency)) def loadDependencyFromDir(self, modular_dir): modular_json_path = os.path.join(modular_dir, 'modular.json') if not os.path.exists(modular_json_path): print('Directory in not a module:', modular_dir) return [] # module_relative - объект класса Module, внутри него в списке dep хранятся прочитанные из json объекты Dependency module_relative = self.loadJsonAtPath(modular_json_path) project_relative = Module() print('module_relative:', module_relative) print(module_relative.cmake) result_inc_dirs = self.addRootToPaths(modular_dir, module_relative.cmake.get('inc_dirs', '')) project_relative.cmake['inc_dirs'] = result_inc_dirs result_srcs = self.addRootToPaths(modular_dir, module_relative.cmake.get('srcs', '')) project_relative.cmake['srcs'] = result_srcs result_libs = self.addRootToPaths(modular_dir, module_relative.cmake.get('libs', '')) project_relative.cmake['libs'] = result_libs result_uis = self.addRootToPaths(modular_dir, module_relative.cmake.get('uis', '')) project_relative.cmake['uis'] = result_uis # После выполнения строки ниже в project_relative.dep окажется просто # список Dependency, который и был в module_relative.dep. # (Это список модулей, от которых текущий модуль зависит. Их надо будет докачать) project_relative.dep = self.addRootToDeps(modular_dir, module_relative.dep) sub_modules = [] for dependency in project_relative.dep: loaded_dependencies = self.loadDependency(dependency) if loaded_dependencies: # sub_modules.extend(loaded_dependencies) # чтобы не было повторений for dep_loaded in loaded_dependencies: if dep_loaded not in sub_modules: sub_modules.append(dep_loaded) else: print('Nothing to download, continuing') sub_modules.append(project_relative) print('sub_modules', sub_modules) return sub_modules def addRootToPaths(self, root, paths): if not paths: return [] result = [] for pth in paths: if os.path.isabs(pth): # если путь абсолютный result.append(pth) else: if pth.startswith('./'): # если текущий путь начинается с текущей папки, то убери ее pth = pth[2:] result.append(os.path.join(root, pth)) return result # эта функция возвращает просто список объектов Dependency def addRootToDeps(self, root, deps): result = [] for dep in deps: if dep.type == DependencyType.local: if not os.path.isabs(dep.dir): dep.dir = os.path.join(root, dep.dir) result.append(dep) return result def download(self): # self.modules - это список из объектов Dependency self.modules = self.loadDependency(Dependency({'type': 'local', 'dir': './'})) print('modules: ') print(self.modules) def replace_slashes(self, path): return path.replace('\\', '/') def genCmake(self): cmakeText = '''function(add_sources FILE_LIST FILES_PATH) file(GLOB_RECURSE ADD_FILES_LIST ${FILES_PATH}) list(APPEND ${FILE_LIST} ${ADD_FILES_LIST}) set(${FILE_LIST} ${${FILE_LIST}} PARENT_SCOPE) endfunction()\n\n''' for module in self.modules: for inc in module.cmake['inc_dirs']: cmakeText += 'include_directories("' + str(inc) + '")\n' cmakeText += '\n' for module in self.modules: for src in module.cmake['srcs']: cmakeText += 'add_sources(SOURCES "' + str(src) + '")\n' cmakeText += '\n' for module in self.modules: for lib in module.cmake['libs']: cmakeText += 'add_sources(LIBRARIES "' + str(lib) + '")\n' cmakeText += '\n' for module in self.modules: for ui in module.cmake['uis']: cmakeText += 'add_sources(UIS "' + str(ui) + '")\n' cmakeText += '\n' # заменяем обратный слеш на прямой, так как винда склеивает своими слешами, но их не поинмает компилятор: cmakeText = self.replace_slashes(cmakeText) print(cmakeText) with open('modular.cmake', 'w', encoding='utf-8') as file: file.write(cmakeText) def genCmakeForLibrify(self): other_path, project_name = os.path.split(os.getcwd()) cmakeText = '''cmake_minimum_required(VERSION 3.17) project(''' + project_name + ''') set(CMAKE_C_STANDARD 11) function(add_sources FILE_LIST FILES_PATH) file(GLOB ADD_FILES_LIST ${FILES_PATH}) list(APPEND ${FILE_LIST} ${ADD_FILES_LIST}) set(${FILE_LIST} ${${FILE_LIST}} PARENT_SCOPE) endfunction()\n\n''' for module in self.modules: for inc in module.cmake['inc_dirs']: cmakeText += 'include_directories("' + str(inc) + '")\n' cmakeText += '\n' module = self.modules[-1] # берем только главный (корневой) модуль for src in module.cmake['srcs']: cmakeText += 'add_sources(SOURCES "' + str(src) + '")\n' cmakeText += '\n' for module in self.modules: for lib in module.cmake['libs']: cmakeText += 'add_sources(LIBRARIES "' + str(lib) + '")\n' cmakeText += '\n' for module in self.modules: for ui in module.cmake['uis']: cmakeText += 'add_sources(UIS "' + str(ui) + '")\n' cmakeText += '\n' cmakeText += 'add_library(' + project_name + ' ${SOURCES})\n' # заменяем обратный слеш на прямой, так как винда склеивает своими слешами, но их не поинмает компилятор: cmakeText = self.replace_slashes(cmakeText) print(cmakeText) with open('modular.cmake', 'w', encoding='utf-8') as file: file.write(cmakeText) def checkDependencyCollision(self, dependency): """Возвращает True, если зависимость dependency уже есть среди загруженных модулей""" # TODO проверка не работает, в self.loadedModules ничего не добавляется, коллизии происходят, ну и пофиг... return any([loaded == dependency for loaded in self.loadedModules]) # def genEclipseXmlPart(self) - эта функция использоваться не будет # def genExp32Cmake(self) - эта функция использоваться не будет def load_rules(self, config_path): try: rules = json.load(open(config_path, encoding='utf-8')) except json.JSONDecodeError: print('Error while reading json file at path:', config_path, file=sys.stderr) print('Try fixing JSON file and run modular.py again', file=sys.stderr) raise json.JSONDecodeError if 'replacements' not in rules: rules['replacements'] = {} if 'filetypes' not in rules: rules['filetypes'] = [".c", ".cpp", ".h"] # если типы файлов не указали, будем менять только в этих if 'remove_comments' not in rules: rules['remove_comments'] = False if 'librify' not in rules: rules['librify'] = [] if 'ignore' not in rules: rules['ignore'] = [] self.rules = rules def replacements_and_comments_deletion(self): project_path = '.' def remove_comments_in_text(string): pattern = r"(\".*?\"|\'.*?\')|(/\*.*?\*/|//[^\r\n]*$)" # первая группа захватывает строки в кавычках (двойных или одинарных) # вторая группа захватывает комментарии ( regex = re.compile(pattern, re.MULTILINE | re.DOTALL) def _replacer(match): # если вторая группа (захватывающая комменты) не None, # значит, мы захватили не закавыченные (нормальные) комменты if match.group(2) is not None: return "" # возвращаем пустую строку для удаления коммента else: # иначе возвращаем 1-ую группу return match.group(1) # поймали "закавыченную" строку return regex.sub(_replacer, string) def remove_comments_in_file(filename): with open(filename, 'r') as f: content = f.read() content_new = remove_comments_in_text(content) with open(filename, 'w') as f: f.write(content_new) def should_exclude_folder(project_path, dirpath, rules): folder_fullpath = os.path.normpath(dirpath) return any([folder_fullpath == os.path.normpath(os.path.join(project_path, ignore_path)) for ignore_path in rules['ignore']]) def replace_occurrences_in_file(filename, rules): with open(filename, 'r') as f: content = f.read() content_new = content for (old_name, new_name) in rules['replacements'].items(): content_new = re.sub(old_name, new_name, content_new) with open(filename, 'w') as f: f.write(content_new) for (dirpath, dirnames, filenames) in os.walk(project_path): # print('*****', dirpath, dirnames, filenames) if should_exclude_folder(project_path, dirpath, self.rules): # если папка входит в игнорируемые, то пропускаем print('Исключена папка:', dirpath) continue for file in filenames: filename, file_extension = os.path.splitext(file) if file_extension not in self.rules['filetypes']: # если расширение не входит в список заданных, то пропускаем этот файт continue fullpath = os.path.join(dirpath, file) print(fullpath) if self.rules['remove_comments']: remove_comments_in_file(fullpath) replace_occurrences_in_file(fullpath, self.rules) def libraryfication(self): initial_wd = os.getcwd() """Собирает библиотеки из модулей в папках, указанных в self.rules["librify"] """ if not os.path.exists('SHRINK'): print('No SHRINK folder, creating...') os.mkdir('SHRINK') print('Done') else: print( 'SHRINK folder already exists. Stopping. \n(If you want to generate libs, delete SHRINK folder first)') return cmake_lists_text = '''include(../arch.cmake) function(add_sources FILE_LIST FILES_PATH) file(GLOB_RECURSE ADD_FILES_LIST ${FILES_PATH}) list(APPEND ${FILE_LIST} ${ADD_FILES_LIST}) endfunction() include(modular.cmake) ''' for directory in self.rules["librify"]: if not os.path.exists(directory): print('Error! Directory from shrink.json does not exists:', directory) raise FileNotFoundError os.chdir(directory) # переходим в папку к этому модулю library_name = directory.split('/')[-1] config = json.load(open('modular.json', encoding='utf-8')) for src in config['cmake']['srcs']: cmake_lists_text += f'add_sources({library_name}_SOURCES "' + '../MODULES/' + library_name + '/' + src + '")\n' cmake_lists_text += 'add_library(' + library_name + ' ${' + library_name + '_SOURCES})' os.chdir(initial_wd) # в конце не забываем вернуться в корневую папку проекта os.chdir('SHRINK') with open('CMakeLists.txt', 'w', encoding='utf-8') as file: file.write(cmake_lists_text) with open('modular.json', 'w') as new_modular: modular_text = '''{ "dep": [ { "type": "local", "dir": "../" } ] }''' new_modular.write(modular_text) result_run = subprocess.run(["modular.py", "-c"]) os.mkdir('build') os.chdir('build') result_run = subprocess.run(["cmake", ".."]) if result_run.returncode != 0: print('cmake not successful. Stop') raise RuntimeError('Previous cmake not successful. Stop') result_run = subprocess.run(["make"]) if result_run.returncode != 0: print('make not successful. Stop') raise RuntimeError('Previous make not successful. Stop') # print('Result code of cmake:', result_run.returncode) os.chdir('..') os.chdir(initial_wd) self.copy_all_libs_to_libs_folder() def copy_all_libs_to_libs_folder(self): current_wd = os.path.abspath(os.getcwd()) try: if os.path.exists('libs'): print('libs already exists') else: os.mkdir('libs') dest_path = os.path.join(current_wd, 'libs') os.chdir('SHRINK') os.chdir('build') for (dirpath, dirnames, filenames) in os.walk('.'): for file in filenames: if file.endswith(".a"): source_file_path = os.path.join(dirpath, file) shutil.copy(source_file_path, dest_path) except FileNotFoundError as e: print('Couldn\'t copy .a libs, error occurred!') print(e) os.chdir(current_wd) # возвращаемся в исходную папку def delete_sources_of_libraries(self): current_wd = os.path.abspath(os.getcwd()) for module_directory in self.rules["librify"]: if not os.path.exists(module_directory): print('Error! Directory from shrink.json does not exists:', module_directory) raise FileNotFoundError os.chdir(module_directory) config = json.load(open('modular.json', encoding='utf-8')) needed_dirs = config['cmake']['inc_dirs'] all_dirs_files = [name for name in os.listdir(os.getcwd()) if os.path.isdir(os.path.join(os.getcwd(), name))] print('all files:', all_dirs_files) for dir in all_dirs_files: if dir not in needed_dirs: print(f'remove dir {dir} in {os.path.join(current_wd, module_directory)}') shutil.rmtree(dir) os.chdir(current_wd) def delete_all_modular_json_files(self): print('Starting to delete all modular.json...') for (dirpath, dirnames, filenames) in os.walk('.'): for file in filenames: if str(file) == 'modular.json': os.remove(os.path.join(dirpath, file)) print('All modular.json files deleted from the project!') def shrink(self): print('Start shrinking...') self.load_rules('shrink.json') self.replacements_and_comments_deletion() self.libraryfication() self.delete_sources_of_libraries() self.delete_all_modular_json_files() print('Done shrinking') def create_release(self, tag=None): """ Сохраняет информацию о текущих версиях каждого из модулей в качестве релиза. В результате в корне проекта создается файл с именем :param tag: :return: """ # without_duplicates = [] # for module in self.modules: # if module not in without_duplicates: # without_duplicates.append(module) # print(without_duplicates) dependencies_without_duplicated = [] for module in self.modules: if module.dep: for dep in module.dep: if dep not in dependencies_without_duplicated: dependencies_without_duplicated.append(dep) # print(dependencies_without_duplicated) all_dependencies_for_project = {"dep": []} for dep in dependencies_without_duplicated: try: path = os.path.join('MODULES', dep.repo) commit_hash = get_git_revision_hash(path) dep_json = { "type": dep.type, "provider": dep.provider, "repo": dep.repo, "commit": commit_hash } all_dependencies_for_project['dep'].append(dep_json) except AttributeError as e: pass # игнорим: 'Dependency' object has no attribute 'repo' (dep=local ./APP) except Exception as e: print(f'Случилось испключение: {e} (dep={dep})') # print(all_dependencies_for_project) release_file_name = datetime.today().strftime('%d.%m.%Y_%H.%M') # if tag: release_file_name += '_' + tag release_file_name += '_release.json' with open(release_file_name, 'w', encoding='utf-8') as output_file: json.dump(all_dependencies_for_project, output_file, ensure_ascii=False, indent=2) print(f'Создан файл релиза: {release_file_name}') def checkout_release(self, json_name): # with open(json_name, encoding='utf-8') as f: # release_json = json.load(f) print('Начинаем переход на указанные коммиты в релизе...') module = self.loadJsonAtPath(json_name) for dependency in module.dep: if dependency.commit: path = self.downloadableModulePath(dependency) checkout_cmd = f'git -C {path} checkout {dependency.commit}' # git -C # Runs the command as if git was started in instead of the current working directory. print(f'Checkout with {checkout_cmd}') os.system(checkout_cmd) # выполняет любые поддерживаемые команды в командной строке print(f'Вернули состояние проекта к релизной версии {json_name}') parser = argparse.ArgumentParser() parser.add_argument('-c', '--cmake', action='store_true', help='require generate cmake from modules') parser.add_argument('-s', '--shrink', action='store_true', help='Согласно правилам из shrink.json выполняет замены по RegEx и при "remove_comments": true удаляет все комментарии из кода') parser.add_argument('-d', '--download', action='store_true', help='require download submodules') parser.add_argument('wdir', help='path to working directory', type=str, default='./', nargs='?') parser.add_argument('-r', '--release', action='store_true', help='Отмечает текущее состояние проекта как релизное, создает соответствующий состоянию json') parser.add_argument('--checkout', type=str, help='Переходит в указанный релиз (выполняет checkout). Для релиза должен быть заранее создан релизный json') argv = parser.parse_args() print('working directory', argv.wdir) os.chdir(argv.wdir) # изменить текущую рабочую директорию project = ModularProject() if argv.download or argv.cmake or argv.shrink: project.download() if argv.cmake: project.genCmake() if argv.shrink: project.shrink() if argv.release: print('release') project.download() project.create_release() if argv.checkout: project.download() project.checkout_release(argv.checkout) # TODO: после checkout проекты будут в состоянии detached HEAH (оторванный указатель HEAD), # и надо уметь какой-то командой возвращаться в исходную позицию (с учитыванием commit, указанного в самом проекте...)