PK5G88fumi/deployments.py# -*- coding: utf-8 -*- # # fumi deployment tool # https://github.com/rmed/fumi # # The MIT License (MIT) # # Copyright (c) 2015 Rafael Medina García # # 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. from __future__ import print_function from blessings import Terminal import datetime import getpass import os import paramiko import scp import subprocess import sys import tarfile term = Terminal() def deploy_local(deployment): """ Local based deployment. """ cprint("> Connecting to %s as %s..." % ( deployment.host, deployment.user), "cyan") ssh = _connect(deployment) cprint("Connected!\n", "green") _run_commands(ssh, deployment.predep) cprint("> Checking remote directory structures...", "cyan") _check_dirs(ssh, deployment) cprint("Correct!\n", "green") # Compress source to temporary directory timestamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") comp_file = timestamp + ".tar.gz" tmp_local = os.path.join("/tmp", comp_file) if deployment.local_ign: cnt_list = list( set(os.listdir(deployment.s_path))^set(deployment.local_ign)) else: cnt_list = os.listdir(deployment.s_path) cprint("> Compressing source to /tmp/%s" % comp_file, "cyan") with tarfile.open(tmp_local, "w:gz") as tar: for item in cnt_list: tar.add( os.path.join(deployment.s_path, item), arcname=timestamp + "/" + item) cprint("Done!\n", "green") # Upload compressed source cprint("> Uploading %s..." % comp_file, "cyan") uload = scp.SCPClient(ssh.get_transport()) uload_tmp = deployment.h_tmp or "/tmp" uload_path = os.path.join(uload_tmp, comp_file) try: uload.put(tmp_local, uload_path) except scp.SCPException as e: cprint("Error uploading to server: %s\n" % e, "red") _rollback(ssh, deployment, timestamp, 3) sys.exit() cprint("Done!\n", "green") # Uncompress source to deploy_path/rev cprint("> Uncompressing remote file...", "cyan") rev_path = os.path.join(deployment.d_path, "rev") untar = "tar -C %s -zxvf %s" % (rev_path, uload_path) stdin, stdout, stderr = ssh.exec_command(untar) status = stdout.channel.recv_exit_status() if status == 127: cprint("Error: tar command not found in remote host\n", "red") _rollback(ssh, deployment, timestamp, 1) sys.exit() elif status == 1: cprint("Error: some files differ\n", "red") print(*stderr.readlines()) _rollback(ssh, deployment, timestamp, 2) sys.exit() elif status == 2: cprint("Fatal error when extracting remote file", "red") print(*stderr.readlines()) _rollback(ssh, deployment, timestamp, 2) sys.exit() cprint("Done!\n", "green") # Link directory _symlink(ssh, deployment.d_path, rev_path, timestamp) # Run post-deployment commands _run_commands(ssh, deployment.postdep, os.path.join(deployment.d_path, "current")) # Clean revisions if deployment.keep: _clean_revisions(ssh, deployment.keep, rev_path) # Cleanup temporary files cprint("> Cleaning temporary files...", "cyan") subprocess.call(["rm", tmp_local]) stdin, stdout, stderr = ssh.exec_command("rm %s" % uload_path) cprint("Done!\n", "green") cprint("Deployment complete!", "green") def deploy_git(deployment): """ Git based deployment. """ cprint("> Connecting to %s as %s..." % ( deployment.host, deployment.user), "cyan") ssh = _connect(deployment) cprint("Connected!\n", "green") _run_commands(ssh, deployment.predep) cprint("> Checking remote directory structures...", "cyan") _check_dirs(ssh, deployment) cprint("Correct!\n", "green") # Clone source cprint("> Cloning repository...", "cyan") timestamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") rev_path = os.path.join(deployment.d_path, "rev") current_rev = os.path.join(deployment.d_path, "rev", timestamp) clone = "git clone %s %s" % (deployment.s_path, current_rev) stdin, stdout, stderr = ssh.exec_command(clone) status = stdout.channel.recv_exit_status() if status == 127: sys.exit(term.bold_red("git command not found in remote server")) # TODO: check git exit codes cprint("Done!\n", "green") # Link directory _symlink(ssh, deployment.d_path, rev_path, timestamp) # Run post-deployment commands _run_commands(ssh, deployment.postdep, os.path.join(deployment.d_path, "current")) # Clean revisions if deployment.keep: _clean_revisions(ssh, deployment.keep, rev_path) cprint("Deployment complete!", "green") def _check_dirs(ssh, dep): """ Check if all the necessary directories exist and the user has permission to write to them. """ # Remote temporary if dep.h_tmp: if not _dir_exists(ssh, dep.h_tmp) and not _create_tree(ssh, dep.h_tmp): sys.exit(term.bold_red( "Cannot create remote temporary directory")) # Deployment path if not _dir_exists(ssh, dep.d_path) and not _create_tree(ssh, dep.d_path): sys.exit(term.bold_red( "Cannot create remote deployment directory")) # Revisions rev = os.path.join(dep.d_path, "rev") if not _dir_exists(ssh, rev) and not _create_tree(ssh, rev): sys.exit(term.bold_red( "Cannot create remote revisions directory")) def _clean_revisions(ssh, keep, rev_path): """ Remove old revisions from the remote server. Only the most recent revisions will be kept, to a maximum defined by the keep argument. """ cprint("> Checking old revisions...", "cyan") stdin, stdout, stderr = ssh.exec_command("ls %s" % rev_path) status = stdout.channel.recv_exit_status() if status == 1 or status == 2: print(*stderr.readlines()) sys.exit(term.bold_red("Error obtaining list of revisions")) revisions = stdout.readlines() if len(revisions) > keep: old_revisions = [] while len(revisions) > keep: old_revisions.append(revisions.pop(0)) for r in old_revisions: cprint("Removing revision %s" % r.strip(), "magenta") rm_old = "rm -rf %s" % os.path.join(rev_path, r.strip()) stdin, stdout, stderr = ssh.exec_command(rm_old) cprint("\nDone!\n", "green") def _connect(deployment): """ Try to connect to the remote host through SSH using information from the provided Deployment object. returns a paramiko.SSHClient instance """ ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) if not deployment.use_password: # Not using password, rely on public key authentication try: cprint("Trying to connect using public key\n", "magenta") ssh.connect(deployment.host, username=deployment.user) except: # May raise AuthenticationException or PasswordRequiredException cprint("Could not connect using public key", "red") sys.exit() elif deployment.use_password and not deployment.password: # Using password but not supplied in the yml file, ask for it cprint("Connection requires a password\n", "magenta") pwd = getpass.getpass("Password: ") try: ssh.connect(deployment.host, username=deployment.user, password=pwd) except: cprint("Could not connect, please check your credentials", "red") sys.exit() elif deployment.use_password and deployment.password: # Using password and supplied in the yml file cprint("Trying to connect using provided password\n", "magenta") try: ssh.connect(deployment.host, username=deployment.user, password=deployment.password) except: cprint("Could not connect, please check your credentials", "red") sys.exit() return ssh def _create_tree(ssh, path): """ Try to create a remote tree path. """ stdin, stdout, stderr = ssh.exec_command( "mkdir -p %s" % path) if stdout.channel.recv_exit_status() == 0: return True return False def _dir_exists(ssh, directory): """ Check whether a remote directory exists or not. """ stdin, stdout, stderr = ssh.exec_command( "[ -d %s ] && echo OK" % directory) result = stdout.read() if result: return True return False def _rollback(ssh, dep, timestamp, level): """ Perform a rollback based on current deployment. Rollbacks have several levels: 1 -> remove locally generated files (compressed source) 2 -> remove uploaded compressed source (if any) 3 -> remove remote revision 4 -> link previous revision All the levels lower to the one provided are executed as well. """ cprint("> Beginning rollback...", "cyan") comp_file = timestamp + ".tar.gz" rev_path = os.path.join(dep.d_path, "rev") if level >= 1: local_file = os.path.join("/tmp", comp_file) if os.path.isfile(local_file): cprint("Removing %s..." % local_file, "magenta") os.remove(local_file) if level >= 2: uload_tmp = dep.h_tmp or "/tmp" remote_file = os.path.join(uload_tmp, comp_file) stdin, stdout, stderr = ssh.exec_command( "[ -f %s ] && echo OK" % remote_file) result = stdout.read() if result: cprint("Removing remote file %s..." % remote_file, "magenta") stdin, stdout, stderr = ssh.exec_command( "rm %s" % remote_file) status = stdout.channel.recv_exit_status() if status < 0: cprint("Remote error", "red") sys.exit(*stderr.readlines()) if level >= 3: stdin, stdout, stderr = ssh.exec_command( "[ -d %s ] && echo OK" % rev_path) result = stdout.read() if result: cprint("Removing remote revision %s..." % timestamp, "magenta") stdin, stdout, stderr = ssh.exec_command( "rm -rf %s" % os.path.join(rev_path, timestamp)) status = stdout.channel.recv_exit_status() if status < 0: cprint("Remote error", "red") sys.exit(*stderr.readlines()) if level >= 4: stdin, stdout, stderr = ssh.exec_command("ls %s" % rev_path) status = stdout.channel.recv_exit_status() revs = [r.rstrip() for r in stdout.readlines()] if timestamp in revs: revs.remove(timestamp) cprint("Linking previous revision %s..." % revs[-1], "magenta") link_path = os.path.join(dep.d_path, "current") ln = "ln -sfn %s %s" % (os.path.join(dep.d_path, "rev", revs[-1]), link_path) stdin, stdout, stderr = ssh.exec_command(ln) cprint("Done!", "green") def _run_commands(ssh, commands, link_path=None): """ Execute pre and post deployment commands (both local and remote). Remote pre-deployment commands are usually executed in the user's directory (~/) when possible, while post-deployment commands are executed in the directory of the revision that has been deployed (deploy_path/current). """ if not commands: return cprint("> Command execution", "cyan") for cmd in commands: k = list(cmd.keys())[0] if k == "local": to_run = cmd[k] cprint("Running local command: %s" % to_run, "magenta") subprocess.Popen(to_run, shell=True).wait() print("\n") elif k == "remote": to_run = cmd[k] # Only for post-deployment commands if link_path: to_run = "cd %s; %s" % (link_path, to_run) cprint("Running remote command: %s" % to_run, "magenta") stdin, stdout, stderr = ssh.exec_command(to_run) # Print command output in real-time while not stdout.channel.exit_status_ready(): if stdout.channel.recv_ready(): print(stdout.readline()) print("\n") else: cprint("Uknown command: %s" % cmd, "red") print("\n") cprint("Done!\n", "green") def _symlink(ssh, d_path, rev_path, timestamp): """ Symlink the deployed revision to the deploy_path/current directory. """ cprint("> Linking directory...", "cyan") link_path = os.path.join(d_path, "current") current_rev = os.path.join(rev_path, timestamp) ln = "ln -sfn %s %s" % (current_rev, link_path) stdin, stdout, stderr = ssh.exec_command(ln) # status = stdout.channel.recv_exit_status() cprint("Done!\n", "green") def cprint(text, color="white", bold=True): """ Print the given text using blessings terminal. """ if color == "cyan": to_print = term.cyan(text) elif color == "green": to_print = term.green(text) elif color == "magenta": to_print = term.magenta(text) elif color == "red": to_print = term.red(text) else: to_print = term.white(text) if bold: print(term.bold(to_print)) else: print(to_print) PKYS6G(CUU fumi/fumi.py# -*- coding: utf-8 -*- # # fumi deployment tool # https://github.com/rmed/fumi # # The MIT License (MIT) # # Copyright (c) 2015 Rafael Medina García # # 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. from __future__ import print_function from fumi import deployments # import deployments # For development import argparse import os import sys import yaml if sys.version[0] == "3": raw_input=input __version__ = "0.3.0" CWD = os.getcwd() DEP_CONF = os.path.join(CWD, 'fumi.yml') class Deployment(object): """ Configuration parsed from the fumi.yml file """ def __init__(self, **kwargs): # Source information self.s_type = kwargs['source-type'] self.s_path = kwargs['source-path'] # Pre-deployment commands self.predep = None predep = kwargs.get('predep') if predep: self.predep = predep # Post-deployment commands self.postdep = None postdep = kwargs.get('postdep') if postdep: self.postdep = postdep # Destination host information self.host = kwargs['host'] self.user = kwargs['user'] self.d_path = kwargs['deploy-path'] self.use_password = kwargs.get('use-password', False) self.password = kwargs.get('password') # Optional information self.h_tmp = kwargs.get('host-tmp') self.keep = kwargs.get('keep-max') self.default = kwargs.get('default') self.local_ign = kwargs.get('local-ignore') def deploy(configuration): """ Deploy with given configuration. """ content = read_yaml() if not content: sys.exit("There is no fumi.yml file") if not configuration: default = None for k in content.keys(): if content[k].get("default"): default = k print("Using default configuration '%s'" % default) break if not default: if len(content.keys()) == 0: sys.exit("There are no configurations") # Ask for default print("I found the following configurations:") for k in content.keys(): print("-", k) default = raw_input("Which one do you want to set as default?: ") if default in content.keys(): content[default]["default"] = True write_yaml(content) else: sys.exit("That configuration does not exist!") configuration = default elif configuration not in content.keys(): sys.exit("Configuration '%s' does not exist" % configuration) # Build deployment object dep = Deployment(**content[configuration]) if dep.s_type == "local": deployments.deploy_local(dep) elif dep.s_type == "git": deployments.deploy_git(dep) def list_configs(): """ List the configurations present in the fumi.yml file. """ content = read_yaml() if not content: sys.exit("There is no fumi.yml file") for conf in content.keys(): default = content[conf].get("default", False) if default: print("- %s (default)" % conf) else: print("- %s" % conf) def new_config(name): """ Create new basic configuration in fumi.yml file. """ content = read_yaml() if not content: content = {} if name in content.keys(): sys.exit("Configuration '%s' already exists" % name) content[name] = { "source-type" : "", "source-path" : "", "predep" : { "local" : None, "remote" : None }, "postdep" : { "local" : None, "remote" : None }, "host" : "", "user" : "", "use-password" : True, "password" : "", "deploy-path" : "", } write_yaml(content) print("Created new blank configuration '%s'" % name) def remove_config(name): """ Remove a configuration from the fumi.yml file. """ content = read_yaml() if not content: sys.exit("There is no fumi.yml file") if name not in content.keys(): sys.exit("Configuration %s does not exist" % name) del content[name] write_yaml(content) print("Removed configuration '%s'" % name) def read_yaml(): """ Reads the fumi.yml file and returns the parsed information in a dict() object. If there is no file, then returns None """ if os.path.isfile(DEP_CONF): with open(DEP_CONF, 'r') as fumi_yml: try: content = yaml.load(fumi_yml) except yaml.YAMLError as e: sys.exit("Error in deployment file:", e) return content return None def write_yaml(content): """ Overwrites the content of the fumi.yml file. """ with open(DEP_CONF, 'w') as fumi_yml: try: yaml.dump(content, fumi_yml, default_flow_style=False) except yaml.YAMLError as e: sys.exit("Error writing yaml to deployment file:", e) def init_parser(): """ Initialize the arguments parser. """ parser = argparse.ArgumentParser( description="Simple deployment tool") parser.add_argument('--version', action='version', version='%(prog)s ' + __version__) subparsers = parser.add_subparsers(title="commands") # deploy parser_deploy = subparsers.add_parser("deploy", help="deploy with given configuration") parser_deploy.add_argument("configuration", nargs="?", help="configuration to use") # list parser_list = subparsers.add_parser("list", help="list all the available deployment configurations") # new parser_new = subparsers.add_parser("new", help="create new deployment configuration") parser_new.add_argument("name", help="name for the configuration") # remove parser_remove = subparsers.add_parser("remove", help="remove a configuration from the deployment file") parser_remove.add_argument("name", help="name of the configuration") return parser def parse_action(action, parsed): """ Parse the action to execute. """ if action == 'deploy': deploy(parsed.configuration) elif action == 'list': list_configs() elif action == 'new': new_config(parsed.name) elif action == 'remove': remove_config(parsed.name) def main(): parser = init_parser() args = parser.parse_args() if len(sys.argv) == 1: # No argument provided parser.print_help() return parse_action(sys.argv[1], args) # Only for development # if __name__ == "__main__": # main() PKwdyFfumi/__init__.pyPKS6G=$fumi-0.3.0.dist-info/DESCRIPTION.rstfumi |PyPI version| =================== A small and (hopefully) simple deployment tool. fumi fetches deployment configurations from a ``fumi.yml`` file. To start using fumi in a project, simply create that file (either manually or with fumi). Installation ------------ :: $ pip install fumi Documentation ------------- Documentation is available online at http://fumi.readthedocs.org. You may also build the documentation using MkDocs: .. code:: shell $ mkdocs build Usage ----- :: usage: fumi [-h] [--version] {deploy,list,new,remove} ... Simple deployment tool optional arguments: -h, --help show this help message and exit --version show program's version number and exit commands: {deploy,list,new,remove} deploy deploy with given configuration list list all the available deployment configurations new create new deployment configuration remove remove a configuration from the deployment file .. |PyPI version| image:: https://img.shields.io/pypi/v/fumi.svg :target: https://pypi.python.org/pypi/fumi PKS6GTs))%fumi-0.3.0.dist-info/entry_points.txt[console_scripts] fumi = fumi.fumi:main PKS6Gf8_]]"fumi-0.3.0.dist-info/metadata.json{"classifiers": ["Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "Topic :: Software Development", "Topic :: Utilities", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4"], "extensions": {"python.commands": {"wrap_console": {"fumi": "fumi.fumi:main"}}, "python.details": {"contacts": [{"email": "rafamedgar@gmail.com", "name": "Rafael Medina Garc\u00eda", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/rmed/fumi"}}, "python.exports": {"console_scripts": {"fumi": "fumi.fumi:main"}}}, "extras": [], "generator": "bdist_wheel (0.24.0)", "keywords": ["fumi", "deploy", "git", "remote", "ssh"], "license": "MIT", "metadata_version": "2.0", "name": "fumi", "run_requires": [{"requires": ["paramiko (==1.15.2)", "scp (==0.10.2)", "pyyaml (==3.11)", "blessings (==1.6)"]}], "summary": "A small and (hopefully) simple deployment tool", "version": "0.3.0"}PKS6G-uW//fumi-0.3.0.dist-info/pbr.json{"is_release": false, "git_version": "4716a71"}PKS6GDT"fumi-0.3.0.dist-info/top_level.txtfumi PKS6G3onnfumi-0.3.0.dist-info/WHEELWheel-Version: 1.0 Generator: bdist_wheel (0.24.0) Root-Is-Purelib: true Tag: py2-none-any Tag: py3-none-any PKS6GjXqfumi-0.3.0.dist-info/METADATAMetadata-Version: 2.0 Name: fumi Version: 0.3.0 Summary: A small and (hopefully) simple deployment tool Home-page: https://github.com/rmed/fumi Author: Rafael Medina García Author-email: rafamedgar@gmail.com License: MIT Keywords: fumi deploy git remote ssh Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: Topic :: Software Development Classifier: Topic :: Utilities Classifier: License :: OSI Approved :: MIT License Classifier: Programming Language :: Python :: 2 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 Requires-Dist: paramiko (==1.15.2) Requires-Dist: scp (==0.10.2) Requires-Dist: pyyaml (==3.11) Requires-Dist: blessings (==1.6) fumi |PyPI version| =================== A small and (hopefully) simple deployment tool. fumi fetches deployment configurations from a ``fumi.yml`` file. To start using fumi in a project, simply create that file (either manually or with fumi). Installation ------------ :: $ pip install fumi Documentation ------------- Documentation is available online at http://fumi.readthedocs.org. You may also build the documentation using MkDocs: .. code:: shell $ mkdocs build Usage ----- :: usage: fumi [-h] [--version] {deploy,list,new,remove} ... Simple deployment tool optional arguments: -h, --help show this help message and exit --version show program's version number and exit commands: {deploy,list,new,remove} deploy deploy with given configuration list list all the available deployment configurations new create new deployment configuration remove remove a configuration from the deployment file .. |PyPI version| image:: https://img.shields.io/pypi/v/fumi.svg :target: https://pypi.python.org/pypi/fumi PKS6G&ziifumi-0.3.0.dist-info/RECORDfumi/deployments.py,sha256=KxYzzf-tuUuFdE8FBLZ4vYG0iIHe8LhAe3zZ4llEeMk,14570 fumi/fumi.py,sha256=aSH7P6yJeBKgnQHd2lA93pzNrBBfKJwiWmChuLO3RO8,7765 fumi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 fumi-0.3.0.dist-info/DESCRIPTION.rst,sha256=5l76YegWxSSUCnc-cjHc4doBSwzfozdZ3HhBEjA1E5I,1182 fumi-0.3.0.dist-info/entry_points.txt,sha256=6lk_YsuGw43Jb0HYiznUIDx9jmBeP98yGq6H2KMwFk4,41 fumi-0.3.0.dist-info/METADATA,sha256=gkPuIcpqbA4M6v32EMCyQVgK4C_zQtcThTh3ZeFL7bg,2031 fumi-0.3.0.dist-info/metadata.json,sha256=mobnMT2r_5Jt1msM76FOUpRZ99MTubznhvGSkFgwOEE,1117 fumi-0.3.0.dist-info/pbr.json,sha256=B17auLRa6mCp55kgvr3ql78JAodd4A95sYigpyTgVCc,47 fumi-0.3.0.dist-info/RECORD,, fumi-0.3.0.dist-info/top_level.txt,sha256=7PM0YKmamPv6ok78Km7VOF4kjMgOBLYn_HkmPmfUvq0,5 fumi-0.3.0.dist-info/WHEEL,sha256=AvR0WeTpDaxT645bl5FQxUK6NPsTls2ttpcGJg3j1Xg,110 PK5G88fumi/deployments.pyPKYS6G(CUU 9fumi/fumi.pyPKwdyFWfumi/__init__.pyPKS6G=$Wfumi-0.3.0.dist-info/DESCRIPTION.rstPKS6GTs))%\fumi-0.3.0.dist-info/entry_points.txtPKS6Gf8_]]"]fumi-0.3.0.dist-info/metadata.jsonPKS6G-uW//afumi-0.3.0.dist-info/pbr.jsonPKS6GDT"bfumi-0.3.0.dist-info/top_level.txtPKS6G3onn`bfumi-0.3.0.dist-info/WHEELPKS6GjXqcfumi-0.3.0.dist-info/METADATAPKS6G&zii0kfumi-0.3.0.dist-info/RECORDPK %n