queue heartbeats and send to wakatime-cli after 4 seconds

This commit is contained in:
Alan Hamlett 2016-04-29 00:18:38 +02:00
parent 260eedb31d
commit 48810f2977
2 changed files with 188 additions and 82 deletions

View file

@ -13,6 +13,7 @@ __version__ = '6.0.8'
import sublime import sublime
import sublime_plugin import sublime_plugin
import json
import os import os
import platform import platform
import re import re
@ -31,6 +32,10 @@ except ImportError:
import winreg # py3 import winreg # py3
except ImportError: except ImportError:
winreg = None winreg = None
try:
import Queue as queue # py2
except ImportError:
import queue # py3
is_py2 = (sys.version_info[0] == 2) is_py2 = (sys.version_info[0] == 2)
@ -89,8 +94,8 @@ LAST_HEARTBEAT = {
'file': None, 'file': None,
'is_write': False, 'is_write': False,
} }
LOCK = threading.RLock()
PYTHON_LOCATION = None PYTHON_LOCATION = None
HEARTBEATS = queue.Queue()
# Log Levels # Log Levels
@ -108,6 +113,20 @@ except ImportError:
pass pass
def set_timeout(callback, seconds):
"""Runs the callback after the given seconds delay.
If this is Sublime Text 3, runs the callback on an alternate thread. If this
is Sublime Text 2, runs the callback in the main thread.
"""
milliseconds = int(seconds * 1000)
try:
sublime.set_timeout_async(callback, milliseconds)
except AttributeError:
sublime.set_timeout(callback, milliseconds)
def log(lvl, message, *args, **kwargs): def log(lvl, message, *args, **kwargs):
try: try:
if lvl == DEBUG and not SETTINGS.get('debug'): if lvl == DEBUG and not SETTINGS.get('debug'):
@ -119,7 +138,24 @@ def log(lvl, message, *args, **kwargs):
msg = message.format(**kwargs) msg = message.format(**kwargs)
print('[WakaTime] [{lvl}] {msg}'.format(lvl=lvl, msg=msg)) print('[WakaTime] [{lvl}] {msg}'.format(lvl=lvl, msg=msg))
except RuntimeError: except RuntimeError:
sublime.set_timeout(lambda: log(lvl, message, *args, **kwargs), 0) set_timeout(lambda: log(lvl, message, *args, **kwargs), 0)
def update_status_bar(status):
"""Updates the status bar."""
try:
if SETTINGS.get('status_bar_message'):
msg = datetime.now().strftime(SETTINGS.get('status_bar_message_fmt'))
if '{status}' in msg:
msg = msg.format(status=status)
active_window = sublime.active_window()
if active_window:
for view in active_window.views():
view.set_status('wakatime', msg)
except RuntimeError:
set_timeout(lambda: update_status_bar(status), 0)
def createConfigFile(): def createConfigFile():
@ -304,10 +340,10 @@ def obfuscate_apikey(command_list):
return cmd return cmd
def enough_time_passed(now, last_heartbeat, is_write): def enough_time_passed(now, is_write):
if now - last_heartbeat['time'] > HEARTBEAT_FREQUENCY * 60: if now - LAST_HEARTBEAT['time'] > HEARTBEAT_FREQUENCY * 60:
return True return True
if is_write and now - last_heartbeat['time'] > 2: if is_write and now - LAST_HEARTBEAT['time'] > 2:
return True return True
return False return False
@ -354,103 +390,173 @@ def is_view_active(view):
def handle_heartbeat(view, is_write=False): def handle_heartbeat(view, is_write=False):
window = view.window() window = view.window()
if window is not None: if window is not None:
target_file = view.file_name() entity = view.file_name()
if entity:
timestamp = time.time()
last_file = LAST_HEARTBEAT['file']
if entity != last_file or enough_time_passed(timestamp, is_write):
project = window.project_data() if hasattr(window, 'project_data') else None project = window.project_data() if hasattr(window, 'project_data') else None
folders = window.folders() folders = window.folders()
thread = SendHeartbeatThread(target_file, view, is_write=is_write, project=project, folders=folders) append_heartbeat(entity, timestamp, is_write, view, project, folders)
def append_heartbeat(entity, timestamp, is_write, view, project, folders):
global LAST_HEARTBEAT
# add this heartbeat to queue
heartbeat = {
'entity': entity,
'timestamp': timestamp,
'is_write': is_write,
'cursorpos': view.sel()[0].begin() if view.sel() else None,
'project': project,
'folders': folders,
}
HEARTBEATS.put_nowait(heartbeat)
# make this heartbeat the LAST_HEARTBEAT
LAST_HEARTBEAT = {
'file': entity,
'time': timestamp,
'is_write': is_write,
}
# process the queue of heartbeats in the future
seconds = 4
set_timeout(process_queue, seconds)
def process_queue():
try:
heartbeat = HEARTBEATS.get_nowait()
except queue.Empty:
return
has_extra_heartbeats = False
extra_heartbeats = []
try:
while True:
extra_heartbeats.append(HEARTBEATS.get_nowait())
has_extra_heartbeats = True
except queue.Empty:
pass
thread = SendHeartbeatsThread(heartbeat)
if has_extra_heartbeats:
thread.add_extra_heartbeats(extra_heartbeats)
thread.start() thread.start()
class SendHeartbeatThread(threading.Thread): class SendHeartbeatsThread(threading.Thread):
"""Non-blocking thread for sending heartbeats to api. """Non-blocking thread for sending heartbeats to api.
""" """
def __init__(self, target_file, view, is_write=False, project=None, folders=None, force=False): def __init__(self, heartbeat):
threading.Thread.__init__(self) threading.Thread.__init__(self)
self.lock = LOCK
self.target_file = target_file
self.is_write = is_write
self.project = project
self.folders = folders
self.force = force
self.debug = SETTINGS.get('debug') self.debug = SETTINGS.get('debug')
self.api_key = SETTINGS.get('api_key', '') self.api_key = SETTINGS.get('api_key', '')
self.ignore = SETTINGS.get('ignore', []) self.ignore = SETTINGS.get('ignore', [])
self.last_heartbeat = LAST_HEARTBEAT.copy()
self.cursorpos = view.sel()[0].begin() if view.sel() else None self.heartbeat = heartbeat
self.view = view self.has_extra_heartbeats = False
def add_extra_heartbeats(self, extra_heartbeats):
self.has_extra_heartbeats = True
self.extra_heartbeats = extra_heartbeats
def run(self): def run(self):
with self.lock: """Running in background thread."""
if self.target_file:
self.timestamp = time.time()
if self.force or self.target_file != self.last_heartbeat['file'] or enough_time_passed(self.timestamp, self.last_heartbeat, self.is_write):
self.send_heartbeat()
def send_heartbeat(self): self.send_heartbeats()
if not self.api_key:
log(ERROR, 'missing api key.') def build_heartbeat(self, entity=None, timestamp=None, is_write=None,
return cursorpos=None, project=None, folders=None):
"""Returns a dict for passing to wakatime-cli as arguments."""
heartbeat = {
'entity': entity,
'timestamp': timestamp,
'is_write': is_write,
}
if project and project.get('name'):
heartbeat['alternate_project'] = project.get('name')
elif folders:
project_name = find_project_from_folders(folders, entity)
if project_name:
heartbeat['alternate_project'] = project_name
if cursorpos is not None:
heartbeat['cursorpos'] = '{0}'.format(cursorpos)
return heartbeat
def send_heartbeats(self):
if python_binary():
heartbeat = self.build_heartbeat(**self.heartbeat)
ua = 'sublime/%d sublime-wakatime/%s' % (ST_VERSION, __version__) ua = 'sublime/%d sublime-wakatime/%s' % (ST_VERSION, __version__)
cmd = [ cmd = [
python_binary(),
API_CLIENT, API_CLIENT,
'--file', self.target_file, '--entity', heartbeat['entity'],
'--time', str('%f' % self.timestamp), '--time', str('%f' % heartbeat['timestamp']),
'--plugin', ua, '--plugin', ua,
'--key', str(bytes.decode(self.api_key.encode('utf8'))),
] ]
if self.is_write: if self.api_key:
cmd.extend(['--key', str(bytes.decode(self.api_key.encode('utf8')))])
if heartbeat['is_write']:
cmd.append('--write') cmd.append('--write')
if self.project and self.project.get('name'): if heartbeat.get('alternate_project'):
cmd.extend(['--alternate-project', self.project.get('name')]) cmd.extend(['--alternate-project', heartbeat['alternate_project']])
elif self.folders: if heartbeat.get('cursorpos') is not None:
project_name = find_project_from_folders(self.folders, self.target_file) cmd.extend(['--cursorpos', heartbeat['cursorpos']])
if project_name:
cmd.extend(['--alternate-project', project_name])
if self.cursorpos is not None:
cmd.extend(['--cursorpos', '{0}'.format(self.cursorpos)])
for pattern in self.ignore: for pattern in self.ignore:
cmd.extend(['--ignore', pattern]) cmd.extend(['--ignore', pattern])
if self.debug: if self.debug:
cmd.append('--verbose') cmd.append('--verbose')
if python_binary(): if self.has_extra_heartbeats:
cmd.insert(0, python_binary()) cmd.append('--extra-heartbeats')
stdin = PIPE
extra_heartbeats = [self.build_heartbeat(**x) for x in self.extra_heartbeats]
extra_heartbeats = json.dumps(extra_heartbeats)
else:
extra_heartbeats = None
stdin = None
log(DEBUG, ' '.join(obfuscate_apikey(cmd))) log(DEBUG, ' '.join(obfuscate_apikey(cmd)))
try: try:
if not self.debug: process = Popen(cmd, stdin=stdin, stdout=PIPE, stderr=STDOUT)
Popen(cmd) inp = None
self.sent() if self.has_extra_heartbeats:
else: inp = "{0}\n".format(extra_heartbeats)
process = Popen(cmd, stdout=PIPE, stderr=STDOUT) inp = inp.encode('utf-8')
output, err = process.communicate() output, err = process.communicate(input=inp)
output = u(output) output = u(output)
retcode = process.poll() retcode = process.poll()
if (not retcode or retcode == 102) and not output: if (not retcode or retcode == 102) and not output:
self.sent() self.sent()
else:
update_status_bar('Error')
if retcode: if retcode:
log(DEBUG if retcode == 102 else ERROR, 'wakatime-core exited with status: {0}'.format(retcode)) log(DEBUG if retcode == 102 else ERROR, 'wakatime-core exited with status: {0}'.format(retcode))
if output: if output:
log(ERROR, u('wakatime-core output: {0}').format(output)) log(ERROR, u('wakatime-core output: {0}').format(output))
except: except:
log(ERROR, u(sys.exc_info()[1])) log(ERROR, u(sys.exc_info()[1]))
update_status_bar('Error')
else: else:
log(ERROR, 'Unable to find python binary.') log(ERROR, 'Unable to find python binary.')
update_status_bar('Error')
def sent(self): def sent(self):
sublime.set_timeout(self.set_status_bar, 0) update_status_bar('OK')
sublime.set_timeout(self.set_last_heartbeat, 0)
def set_status_bar(self):
if SETTINGS.get('status_bar_message'):
self.view.set_status('wakatime', datetime.now().strftime(SETTINGS.get('status_bar_message_fmt')))
def set_last_heartbeat(self): def download_python():
global LAST_HEARTBEAT thread = DownloadPython()
LAST_HEARTBEAT = { thread.start()
'file': self.target_file,
'time': self.timestamp,
'is_write': self.is_write,
}
class DownloadPython(threading.Thread): class DownloadPython(threading.Thread):
@ -491,15 +597,15 @@ class DownloadPython(threading.Thread):
def plugin_loaded(): def plugin_loaded():
global SETTINGS global SETTINGS
log(INFO, 'Initializing WakaTime plugin v%s' % __version__)
SETTINGS = sublime.load_settings(SETTINGS_FILE) SETTINGS = sublime.load_settings(SETTINGS_FILE)
log(INFO, 'Initializing WakaTime plugin v%s' % __version__)
update_status_bar('Initializing')
if not python_binary(): if not python_binary():
log(WARNING, 'Python binary not found.') log(WARNING, 'Python binary not found.')
if platform.system() == 'Windows': if platform.system() == 'Windows':
thread = DownloadPython() set_timeout(download_python, 0)
thread.start()
else: else:
sublime.error_message("Unable to find Python binary!\nWakaTime needs Python to work correctly.\n\nGo to https://www.python.org/downloads") sublime.error_message("Unable to find Python binary!\nWakaTime needs Python to work correctly.\n\nGo to https://www.python.org/downloads")
return return
@ -509,7 +615,7 @@ def plugin_loaded():
def after_loaded(): def after_loaded():
if not prompt_api_key(): if not prompt_api_key():
sublime.set_timeout(after_loaded, 500) set_timeout(after_loaded, 0.5)
# need to call plugin_loaded because only ST3 will auto-call it # need to call plugin_loaded because only ST3 will auto-call it

View file

@ -19,5 +19,5 @@
"status_bar_message": true, "status_bar_message": true,
// Status bar message format. // Status bar message format.
"status_bar_message_fmt": "WakaTime active %I:%M %p" "status_bar_message_fmt": "WakaTime {status} %I:%M %p"
} }