def __enter__(self): logger.debug('MicrophoneStream.enter ENTER') self.closed = False try: self._audio_stream = self._audio_interface.open( input_device_index=self._inputDeviceIndex, format=self._format, channels=self._num_channels, rate=self._rate, input=True, frames_per_buffer=self._chunk_size, # Run the audio stream asynchronously to fill the buffer object. # This is necessary so that the input device's buffer doesn't # overflow while the calling thread makes network requests, etc. stream_callback=self._fill_buff, ) except OSError: logger.error("microphone __enter__.OSError") self.closed = True raise Exception("Microphone Not Functioning") self.initRecording() logger.debug('MicrophoneStream.enter EXIT') return self
def canceled(self, evt): logger.debug( 'microsoftTranscribe.ProcessEvents.CANCELED: {}'.format(evt)) logger.error( 'microsoftTranscribe terminated. Ensure you have the correct API Key and service region.' ) self.responseQueue.put('canceled')
def check_folder_writable(folder, fallback, name): if not folder: folder = fallback if not os.path.exists(folder): try: os.makedirs(folder) except OSError as e: logger.error("Could not create %s dir '%s': %s" % (name, folder, e)) if folder != fallback: logger.warn("Falling back to %s dir '%s'" % (name, fallback)) return check_folder_writable(None, fallback, name) else: return folder, None if not os.access(folder, os.W_OK): logger.error("Cannot write to %s dir '%s'" % (name, folder)) if folder != fallback: logger.warn("Falling back to %s dir '%s'" % (name, fallback)) return check_folder_writable(None, fallback, name) else: return folder, False return folder, True
def write(self): """ Make a copy of the stored config and write it to the configured file """ new_config = ConfigObj(encoding="UTF-8") new_config.filename = self._config_file # first copy over everything from the old config, even if it is not # correctly defined to keep from losing data for key, subkeys in self._config.items(): if key not in new_config: new_config[key] = {} for subkey, value in subkeys.items(): new_config[key][subkey] = value # next make sure that everything we expect to have defined is so for key in _CONFIG_DEFINITIONS.keys(): key, definition_type, section, ini_key, default = self._define(key) self.check_setting(key) if section not in new_config: new_config[section] = {} new_config[section][ini_key] = self._config[section][ini_key] # Write it to file logger.info("Config :: Writing configuration to file") try: new_config.write() except IOError as e: logger.error("Config :: Error writing configuration file: %s", e)
def setReleaseLock(self, release): # Set the previous release in the lock file try: with open(self.release_lock_file, "w") as fp: fp.write(release) except IOError as e: logger.error(u"Unable to write current version to file '%s': %s" % (self.release_lock_file, e))
def setVersionLock(self, version_hash): # Set the previous version in the lock file try: with open(self.version_lock_file, "w") as fp: fp.write(version_hash) except IOError as e: logger.error(u"Unable to write current version to file '%s': %s" % (self.version_lock_file, e))
def run(self): if self._ONLINE: logger.warn("Transcribe Engine already Started") return logger.info( "Transcribe Engine Starting with the %s%s Speech-To-Text Service" % (speakreader.CONFIG.SPEECH_TO_TEXT_SERVICE[0].upper(), speakreader.CONFIG.SPEECH_TO_TEXT_SERVICE[1:])) FILENAME_DATESTRING = datetime.datetime.now().strftime( FILENAME_DATE_FORMAT) TRANSCRIPT_FILENAME = FILENAME_PREFIX + FILENAME_DATESTRING + "." + TRANSCRIPT_FILENAME_SUFFIX RECORDING_FILENAME = FILENAME_PREFIX + FILENAME_DATESTRING + "." + RECORDING_FILENAME_SUFFIX tf = os.path.join(speakreader.CONFIG.TRANSCRIPTS_FOLDER, TRANSCRIPT_FILENAME) self.queueManager.transcriptHandler.setFileName(tf) try: self.microphoneStream = MicrophoneStream( speakreader.CONFIG.INPUT_DEVICE) self.microphoneStream.recordingFilename = RECORDING_FILENAME self.microphoneStream.meterQueue = self.queueManager.meterHandler.getReceiverQueue( ) except Exception as e: logger.debug("MicrophoneStream Exception: %s" % e) self.transcriptQueue.put_nowait(self.OFFLINE_MESSAGE) return if speakreader.CONFIG.SPEECH_TO_TEXT_SERVICE == 'google' and self.GOOGLE_SERVICE: transcribeService = googleTranscribe(self.microphoneStream) elif speakreader.CONFIG.SPEECH_TO_TEXT_SERVICE == 'IBM' and self.IBM_SERVICE: transcribeService = ibmTranscribe(self.microphoneStream) elif speakreader.CONFIG.SPEECH_TO_TEXT_SERVICE == 'microsoft' and self.MICROSOFT_SERVICE: transcribeService = microsoftTranscribe(self.microphoneStream) else: logger.warn( "No Supported Transcribe Service Selected. Can't start Transcribe Engine." ) return self.transcriptFile = open(tf, "a+") self.transcriptQueue.put_nowait(self.ONLINE_MESSAGE) self._ONLINE = True try: with self.microphoneStream as stream: while self._ONLINE: responses = transcribeService.transcribe() self.process_responses(responses) logger.info("Transcription Engine Stream Closed") except Exception as e: logger.error(e) self.transcriptFile.close() self.transcriptQueue.put_nowait(self.OFFLINE_MESSAGE) self._ONLINE = False logger.info("Transcribe Engine Terminated")
def checkout_git_branch(self, git_remote=None, git_branch=None, **kwargs): if git_branch == speakreader.CONFIG.GIT_BRANCH: logger.error(u"Already on the %s branch" % git_branch) raise cherrypy.HTTPRedirect(speakreader.CONFIG.HTTP_ROOT + "manage") # Set the new git remote and branch speakreader.CONFIG.__setattr__('GIT_REMOTE', git_remote) speakreader.CONFIG.__setattr__('GIT_BRANCH', git_branch) speakreader.CONFIG.write() return self.do_state_change('checkout', 'Switching Git Branches', 120)
def pip_update(self): logger.info("Running pip_update to update the installation tools.") try: cmd = sys.executable + ' -m pip install --upgrade pip setuptools wheel pip-tools' output = self._exec_command(cmd) for line in output: logger.info('pip_update output: %s' % line) return True except Exception as e: logger.error('Command failed: %s' % e) return False
def pip_sync(self): logger.info("Running pip-sync to synchronize the environment.") try: cmd = sys.executable + ' -m piptools sync requirements.txt' output = self._exec_command(cmd) for line in output: logger.info('pip-sync output: %s' % line) return True except Exception as e: logger.error('Command failed: %s' % e) return False
def getVersionLock(self): # Get the previous version from the file version_hash = "unknown" if os.path.isfile(self.version_lock_file): try: with open(self.version_lock_file, "r") as fp: version_hash = fp.read() except IOError as e: logger.error( "Unable to read previous version from file '%s': %s" % (self.version_lock_file, e)) return version_hash
def getReleaseLock(self): # Get the previous release from the lock file release = False if os.path.isfile(self.release_lock_file): try: with open(self.release_lock_file, "r") as fp: release = fp.read() except IOError as e: logger.error( "Unable to read previous release from file '%s': %s" % (self.release_lock_file, e)) return release
def launch_browser(host, port, root): if not no_browser: if host == '0.0.0.0': host = 'localhost' if CONFIG.ENABLE_HTTPS: protocol = 'https' else: protocol = 'http' try: webbrowser.open('%s://%s:%i%s' % (protocol, host, port, root)) except Exception as e: logger.error("Could not launch browser: %s" % e)
def runGit(self, args): if speakreader.CONFIG.GIT_PATH: git_locations = ['"' + speakreader.CONFIG.GIT_PATH + '"'] else: git_locations = ['git'] output = err = None for cur_git in git_locations: cmd = cur_git + ' ' + args try: logger.debug('Trying to execute: "' + cmd + '" with shell in ' + speakreader.PROG_DIR) p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=speakreader.PROG_DIR) output, err = p.communicate() output = output.decode('utf-8').strip() for line in output.split('\n'): if line: logger.debug('Git output: ' + line) except OSError: logger.debug('Command failed: %s', cmd) continue if 'not found' in output or "not recognized as an internal or external command" in output: logger.debug('Unable to find git with command ' + cmd) output = None elif 'fatal:' in output or err: logger.error( 'Git returned bad info. Are you sure this is a git installation?' ) output = None elif output: break return (output, err)
def checkout_git_branch(self): if self.INSTALL_TYPE == 'git': output, err = self.runGit('fetch %s' % speakreader.CONFIG.GIT_REMOTE) output, err = self.runGit('checkout %s' % speakreader.CONFIG.GIT_BRANCH) if not output: logger.error('Unable to change git branch.') return for line in output.split('\n'): if line.endswith(('Aborting', 'Aborting.')): logger.error('Unable to checkout from git: ' + line) logger.info('Output: ' + str(output)) output, err = self.runGit( 'pull %s %s' % (speakreader.CONFIG.GIT_REMOTE, speakreader.CONFIG.GIT_BRANCH)) self.pip_update() self.pip_sync()
def update(self): if speakreader.CONFIG.SERVER_ENVIRONMENT.lower() != 'production': logger.info( "Updating bypassed because this is not a production environment" ) return False if not self.UPDATE_AVAILABLE: logger.info("No Updates Available") return False if self.INSTALL_TYPE == 'git': output, err = self.runGit( 'diff --name-only %s/%s' % (speakreader.CONFIG.GIT_REMOTE, speakreader.CONFIG.GIT_BRANCH)) if output == '': logger.debug("No differences found from the origin") elif output == 'requirements.txt': logger.warn( 'Requirements file is out of sync. Restoring to original.') output, err = self.runGit('checkout %s/%s requirements.txt' % (speakreader.CONFIG.GIT_REMOTE, speakreader.CONFIG.GIT_BRANCH)) else: logger.error("Differences Found. Unable to update.") logger.info('Output: ' + str(output)) return False output, err = self.runGit('pull ' + speakreader.CONFIG.GIT_REMOTE + ' ' + speakreader.CONFIG.GIT_BRANCH) if not output: logger.error('Unable to download latest version') return False for line in output.split('\n'): if 'Already up-to-date.' in line: logger.info('No update available, not updating') logger.info('Output: ' + str(output)) return False elif line.endswith(('Aborting', 'Aborting.')): logger.error('Unable to update from git: ' + line) logger.info('Output: ' + str(output)) return False else: tar_download_url = 'https://api.github.com/repos/{}/{}/tarball/{}'.format( speakreader.CONFIG.GIT_USER, speakreader.CONFIG.GIT_REPO, speakreader.CONFIG.GIT_BRANCH) if speakreader.CONFIG.GIT_TOKEN: tar_download_url = tar_download_url + '?access_token=%s' % speakreader.CONFIG.GIT_TOKEN update_dir = os.path.join(speakreader.PROG_DIR, 'update') version_path = os.path.join(speakreader.PROG_DIR, 'version.txt') logger.info('Downloading update from: ' + tar_download_url) try: data = requests.get(tar_download_url, timeout=10) except Exception as e: logger.warn('Failed to establish a connection to GitHub') return False if not data: logger.error( "Unable to retrieve new version from '%s', can't update", tar_download_url) return False download_name = speakreader.CONFIG.GIT_BRANCH + '-github' tar_download_path = os.path.join(speakreader.PROG_DIR, download_name) # Save tar to disk with open(tar_download_path, 'wb') as f: f.write(data.content) # Extract the tar to update folder logger.info('Extracting file: ' + tar_download_path) tar = tarfile.open(tar_download_path) tar.extractall(update_dir) tar.close() # Delete the tar.gz logger.info('Deleting file: ' + tar_download_path) os.remove(tar_download_path) # Find update dir name update_dir_contents = [ x for x in os.listdir(update_dir) if os.path.isdir(os.path.join(update_dir, x)) ] if len(update_dir_contents) != 1: logger.error("Invalid update data, update failed: " + str(update_dir_contents)) return False content_dir = os.path.join(update_dir, update_dir_contents[0]) # walk temp folder and move files to main folder for dirname, dirnames, filenames in os.walk(content_dir): dirname = dirname[len(content_dir) + 1:] for curfile in filenames: old_path = os.path.join(content_dir, dirname, curfile) new_path = os.path.join(speakreader.PROG_DIR, dirname, curfile) if os.path.isfile(new_path): os.remove(new_path) os.renames(old_path, new_path) # Update version.txt try: with open(version_path, 'w') as f: f.write(str(self.LATEST_VERSION_HASH)) except IOError as e: logger.error( "Unable to write current version to version.txt, update not complete: %s" % e) return False self.pip_update() self.pip_sync() logger.info("Update Complete") return True
def main(): """ SpeakReader application entry point. Parses arguments, setups encoding and initializes the application. """ DAEMON = False NOFORK = False QUIET = False VERBOSE = False NOLAUNCH = False HTTP_PORT = None PLATFORM = system() PLATFORM_RELEASE = release() PLATFORM_VERSION = version() PLATFORM_LINUX_DISTRO = None PLATFORM_PROCESSOR = processor() PLATFORM_MACHINE = machine() PLATFORM_IS_64BITS = sys.maxsize > 2**32 SYS_PLATFORM = sys.platform # Fixed paths to application if hasattr(sys, 'frozen'): FULL_PATH = os.path.abspath(sys.executable) else: FULL_PATH = os.path.abspath(__file__) PROG_DIR = os.path.dirname(FULL_PATH) # Ensure only one instance of SpeakReader running. PIDFILE = os.path.join(PROG_DIR, 'pidfile') myPid = os.getpid() if os.path.exists(PIDFILE) and os.path.isfile(PIDFILE): for p in psutil.process_iter(): if 'python' in p.name() and p.pid != myPid: for f in p.open_files(): if f.path == PIDFILE: logger.error( "SpeakReader is already Running. Exiting.") sys.exit(0) myPidFile = open(PIDFILE, 'w+') myPidFile.write(str(myPid)) myPidFile.flush() try: locale.setlocale(locale.LC_ALL, "") except (locale.Error, IOError): pass try: SYS_TIMEZONE = str(tzlocal.get_localzone()) SYS_UTC_OFFSET = datetime.datetime.now( pytz.timezone(SYS_TIMEZONE)).strftime('%z') except (pytz.UnknownTimeZoneError, LookupError, ValueError) as e: logger.error("Could not determine system timezone: %s" % e) SYS_TIMEZONE = 'Unknown' SYS_UTC_OFFSET = '+0000' # Parse any passed startup arguments ARGS = sys.argv[1:] # Set up and gather command line arguments parser = argparse.ArgumentParser( description= 'A Python based monitoring and tracking tool for Plex Media Server.') parser.add_argument('-v', '--verbose', action='store_true', help='Increase console logging verbosity') parser.add_argument('-q', '--quiet', action='store_true', help='Turn off console logging') parser.add_argument('-d', '--daemon', action='store_true', help='Run as a daemon') parser.add_argument('-p', '--port', type=int, help='Force SpeakReader to run on a specified port') parser.add_argument( '--datadir', help='Specify a directory where to store your data files') parser.add_argument('--config', help='Specify a config file to use') parser.add_argument('--nolaunch', action='store_true', help='Prevent browser from launching on startup') parser.add_argument( '--nofork', action='store_true', help='Start SpeakReader as a service, do not fork when restarting') args = parser.parse_args() # Force the http port if necessary if args.port: HTTP_PORT = args.port logger.info('Using forced web server port: %i', HTTP_PORT) # Don't launch the browser if args.nolaunch: NOLAUNCH = True if args.verbose: VERBOSE = True if args.quiet: QUIET = True if args.daemon: if SYS_PLATFORM == 'win32': sys.stderr.write( "Daemonizing not supported under Windows, starting normally\n") else: DAEMON = True QUIET = True if args.nofork: NOFORK = True logger.info( "SpeakReader is running as a service, it will not fork when restarted." ) # Determine which data directory and config file to use if args.datadir: DATA_DIR = args.datadir else: DATA_DIR = os.path.join(PROG_DIR, 'data') if args.config: CONFIG_FILE = args.config else: CONFIG_FILE = os.path.join(DATA_DIR, config.FILENAME) # Try to create the DATA_DIR if it doesn't exist if not os.path.exists(DATA_DIR): try: os.makedirs(DATA_DIR) except OSError: raise SystemExit('Could not create data directory: ' + DATA_DIR + '. Exiting....') # Make sure the DATA_DIR is writeable if not os.access(DATA_DIR, os.W_OK): raise SystemExit('Cannot write to the data directory: ' + DATA_DIR + '. Exiting...') # Do an initial setup of the logging. logger.initLogger(console=not QUIET, log_dir=False, verbose=VERBOSE) # Initialize the configuration from the config file CONFIG = config.Config(CONFIG_FILE) assert CONFIG is not None if CONFIG.SERVER_ENVIRONMENT.lower() != 'production': VERBOSE = True CONFIG.LOG_DIR, log_writable = check_folder_writable( CONFIG.LOG_DIR, os.path.join(DATA_DIR, 'logs'), 'logs') if not log_writable and not QUIET: sys.stderr.write( "Unable to create the log directory. Logging to screen only.\n") # Start the logger, disable console if needed logger.initLogger(console=not QUIET, log_dir=CONFIG.LOG_DIR if log_writable else None, verbose=VERBOSE) logger.info("Initializing {} {}".format(speakreader.PRODUCT, speakreader.VERSION_RELEASE)) logger.info("{} {} ({}{})".format( PLATFORM, PLATFORM_RELEASE, PLATFORM_VERSION, ' - {}'.format(PLATFORM_LINUX_DISTRO) if PLATFORM_LINUX_DISTRO else '')) logger.info("{} ({} {})".format( PLATFORM_PROCESSOR, PLATFORM_MACHINE, '{}'.format('64-BIT') if PLATFORM_IS_64BITS else '32-BIT')) logger.info("Python {}".format(sys.version)) logger.info("{} (UTC{})".format(SYS_TIMEZONE, SYS_UTC_OFFSET)) logger.info("Program Dir: {}".format(PROG_DIR)) logger.info("Config File: {}".format(CONFIG_FILE)) CONFIG.TRANSCRIPTS_FOLDER, _ = check_folder_writable( CONFIG.TRANSCRIPTS_FOLDER, os.path.join(DATA_DIR, 'transcripts'), 'transcripts') CONFIG.RECORDINGS_FOLDER, _ = check_folder_writable( CONFIG.RECORDINGS_FOLDER, os.path.join(DATA_DIR, 'recordings'), 'recordings') if DAEMON: daemonize(myPidFile) # Store the original umask UMASK = os.umask(0) os.umask(UMASK) initOptions = { 'config': CONFIG, 'http_port': HTTP_PORT, 'nolaunch': NOLAUNCH, 'prog_dir': PROG_DIR, 'data_dir': DATA_DIR, } # Read config and start logging global SR SR = SpeakReader(initOptions) # Wait endlessly for a signal to happen restart = False checkout = False update = False while True: if not SR.SIGNAL: try: time.sleep(1) except KeyboardInterrupt: SR.SIGNAL = 'shutdown' else: logger.info('Received signal: %s', SR.SIGNAL) if SR.SIGNAL == 'shutdown': break elif SR.SIGNAL == 'restart': restart = True break elif SR.SIGNAL == 'update': restart = True update = True break elif SR.SIGNAL == 'checkout': restart = True checkout = True break else: SR.SIGNAL = None SR.shutdown(restart=restart, update=update, checkout=checkout) myPidFile.close() os.remove(PIDFILE) if restart: logger.info("SpeakReader is restarting...") exe = sys.executable args = [exe, FULL_PATH] args += ARGS if '--nolaunch' not in args: args += ['--nolaunch'] # Separate out logger so we can shutdown logger after if NOFORK: logger.info('Running as service, not forking. Exiting...') elif os.name == 'nt': logger.info('Restarting SpeakReader with %s', args) else: logger.info('Restarting SpeakReader with %s', args) logger.shutdown() if NOFORK: pass elif os.name == 'nt': subprocess.Popen(args, cwd=os.getcwd()) else: os.execv(exe, args) else: logger.info("SpeakReader Terminated") logger.shutdown() sys.exit(0)
def __init__(self): self.INSTALL_TYPE = None self.INSTALLED_VERSION_HASH = None self.INSTALLED_RELEASE = speakreader.VERSION_RELEASE self.LATEST_VERSION_HASH = None self.LATEST_RELEASE = "Unknown" self.LATEST_RELEASE_URL = "" self.COMMITS_BEHIND = 0 self.UPDATE_AVAILABLE = False self.REMOTE_NAME = None self.BRANCH_NAME = None self.version_lock_file = os.path.join(speakreader.PROG_DIR, "version.lock") self.release_lock_file = os.path.join(speakreader.PROG_DIR, "release.lock") installed_release = self.getReleaseLock() if self.INSTALLED_RELEASE != installed_release: self.setReleaseLock(self.INSTALLED_RELEASE) if os.path.isdir(os.path.join(speakreader.PROG_DIR, '.git')): self.INSTALL_TYPE = 'git' output, err = self.runGit('rev-parse HEAD') if output: if re.match('^[a-z0-9]+$', output): self.INSTALLED_VERSION_HASH = output installed_version_hash = self.getVersionLock() if self.INSTALLED_VERSION_HASH != installed_version_hash: self.setVersionLock(self.INSTALLED_VERSION_HASH) else: logger.error( 'Output does not look like a hash, not using it.') else: logger.error('Could not find latest installed version.') if speakreader.CONFIG.DO_NOT_OVERRIDE_GIT_BRANCH and speakreader.CONFIG.GIT_BRANCH: self.BRANCH_NAME = speakreader.CONFIG.GIT_BRANCH else: remote_branch, err = self.runGit( 'rev-parse --abbrev-ref --symbolic-full-name @{u}') remote_branch = remote_branch.rsplit( '/', 1) if remote_branch else [] if len(remote_branch) == 2: self.REMOTE_NAME, self.BRANCH_NAME = remote_branch if not self.REMOTE_NAME and speakreader.CONFIG.GIT_REMOTE: logger.error( 'Could not retrieve remote name from git. Falling back to %s.' % speakreader.CONFIG.GIT_REMOTE) self.REMOTE_NAME = speakreader.CONFIG.GIT_REMOTE if not self.REMOTE_NAME: logger.error( 'Could not retrieve remote name from git. Defaulting to origin.' ) self.REMOTE_NAME = 'origin' if not self.BRANCH_NAME and speakreader.CONFIG.GIT_BRANCH: logger.error( 'Could not retrieve branch name from git. Falling back to %s.' % speakreader.CONFIG.GIT_BRANCH) self.BRANCH_NAME = speakreader.CONFIG.GIT_BRANCH if not self.BRANCH_NAME: logger.error( 'Could not retrieve branch name from git. Defaulting to master.' ) self.BRANCH_NAME = 'master' speakreader.CONFIG.GIT_REMOTE = self.REMOTE_NAME speakreader.CONFIG.GIT_BRANCH = self.BRANCH_NAME else: self.INSTALL_TYPE = 'source' self.REMOTE_NAME = 'origin' self.BRANCH_NAME = speakreader.GITHUB_BRANCH self.INSTALLED_VERSION_HASH = self.getVersionLock() if speakreader.CONFIG.CHECK_GITHUB: self.checkForUpdate()
def read_changelog(self, latest_only=False, since_prev_release=False): changelog_file = os.path.join(speakreader.PROG_DIR, 'CHANGELOG.md') if not os.path.isfile(changelog_file): return '<h4>Missing changelog file</h4>' try: output = [''] prev_level = 0 latest_version_found = False header_pattern = re.compile(r'(^#+)\s(.+)') list_pattern = re.compile(r'(^[ \t]*\*\s)(.+)') with open(changelog_file, "r") as logfile: for line in logfile: line_header_match = re.search(header_pattern, line) line_list_match = re.search(list_pattern, line) if line_header_match: header_level = str(len(line_header_match.group(1))) header_text = line_header_match.group(2) if header_text.lower() == 'changelog': continue if latest_version_found: break elif latest_only: latest_version_found = True # Add a space to the end of the release to match tags elif since_prev_release and str( self.INSTALLED_RELEASE) + ' ' in header_text: break output[ -1] += '<h' + header_level + '>' + header_text + '</h' + header_level + '>' elif line_list_match: line_level = len(line_list_match.group(1)) / 2 line_text = line_list_match.group(2) if line_level > prev_level: output[-1] += '<ul>' * int( line_level - prev_level) + '<li>' + line_text + '</li>' elif line_level < prev_level: output[-1] += '</ul>' * int( prev_level - line_level) + '<li>' + line_text + '</li>' else: output[-1] += '<li>' + line_text + '</li>' prev_level = line_level elif line.strip() == '' and prev_level: output[-1] += '</ul>' * int(prev_level) output.append('') prev_level = 0 if since_prev_release: output.reverse() return ''.join(output) except IOError as e: logger.error('Unable to open changelog file. %s' % e) return '<h4>Unable to open changelog file</h4>'