version 0.2.1. using new actions api scheme.

This commit is contained in:
Alan Hamlett 2013-07-07 18:38:01 -07:00
parent 227b7197d3
commit 2357f1325c
14 changed files with 3056 additions and 223 deletions

View file

@ -1,185 +0,0 @@
#!/usr/bin/env python
import os
import sys
import argparse
import platform
import urllib2
import json
import base64
import uuid
import time
import re
from collections import OrderedDict
from httplib import BadStatusLine, IncompleteRead
from urllib2 import HTTPError, URLError
import logging as log
# Config
version = '0.1.0'
user_agent = 'sublime-wakatime/%s (%s)' % (version, platform.platform())
def project_from_path(path):
project = git_project(path)
if project:
return project
return None
def tags_from_path(path):
tags = []
if os.path.exists(path):
tags.extend(git_tags(path))
tags.extend(mercurial_tags(path))
return list(set(tags))
def git_project(path):
config_file = find_git_config(path)
if config_file:
folder = os.path.split(os.path.split(os.path.split(config_file)[0])[0])[1]
if folder:
return folder
return None
def find_git_config(path):
path = os.path.realpath(path)
if os.path.isfile(path):
path = os.path.split(path)[0]
if os.path.isfile(os.path.join(path, '.git', 'config')):
return os.path.join(path, '.git', 'config')
split_path = os.path.split(path)
if split_path[1] == '':
return None
return find_git_config(split_path[0])
def parse_git_config(config):
sections = OrderedDict()
try:
f = open(config, 'r')
except IOError as e:
log.exception("Exception:")
else:
with f:
section = None
for line in f.readlines():
line = line.lstrip()
if len(line) > 0 and line[0] == '[':
section = line[1:].split(']', 1)[0]
temp = section.split(' ', 1)
section = temp[0].lower()
if len(temp) > 1:
section = ' '.join([section, temp[1]])
sections[section] = OrderedDict()
else:
try:
(setting, value) = line.split('=', 1)
except ValueError:
setting = line.split('#', 1)[0].split(';', 1)[0]
value = 'true'
setting = setting.strip().lower()
value = value.split('#', 1)[0].split(';', 1)[0].strip()
sections[section][setting] = value
f.close()
return sections
def git_tags(path):
tags = []
config_file = find_git_config(path)
if config_file:
sections = parse_git_config(config_file)
for section in sections:
if section.split(' ', 1)[0] == 'remote' and 'url' in sections[section]:
tags.append(sections[section]['url'])
return tags
def mercurial_tags(path):
tags = []
return tags
def svn_tags(path):
tags = []
return tags
def log_action(**kwargs):
kwargs['User-Agent'] = user_agent
log.info(json.dumps(kwargs))
def send_action(key, instance, action, task, timestamp, project, tags):
url = 'https://www.wakati.me/api/v1/actions'
data = {
'type': action,
'task': os.path.realpath(task),
'time': timestamp,
'instance_id': instance,
'project': project,
'tags': tags,
}
request = urllib2.Request(url=url, data=json.dumps(data))
request.add_header('User-Agent', user_agent)
request.add_header('Content-Type', 'application/json')
request.add_header('Authorization', 'Basic %s' % base64.b64encode(key))
log_action(**data)
response = None
try:
response = urllib2.urlopen(request)
except HTTPError as ex:
log.error("%s:\ndata=%s\nresponse=%s" % (ex.getcode(), json.dumps(data), ex.read()))
if log.getLogger().isEnabledFor(log.DEBUG):
log.exception("Exception for %s:\n%s" % (data['time'], json.dumps(data)))
except (URLError, IncompleteRead, BadStatusLine) as ex:
log.error("%s:\ndata=%s\nmessage=%s" % (ex.__class__.__name__, json.dumps(data), ex))
if log.getLogger().isEnabledFor(log.DEBUG):
log.exception("Exception for %s:\n%s" % (data['time'], json.dumps(data)))
if response:
log.debug('response_code=%s response_content=%s' % (response.getcode(), response.read()))
if response and (response.getcode() == 200 or response.getcode() == 201):
return True
return False
def parse_args(argv):
parser = argparse.ArgumentParser(description='Log time to the wakati.me api')
parser.add_argument('--key', dest='key', required=True,
help='your wakati.me api key')
parser.add_argument('--action', dest='action', required=True,
choices=['open_file', 'ping', 'close_file', 'write_file', 'open_editor', 'quit_editor', 'minimize_editor', 'maximize_editor', 'start', 'stop'])
parser.add_argument('--task', dest='task', required=True,
help='path to file or named task')
parser.add_argument('--instance', dest='instance', required=True,
help='the UUID4 representing the current editor')
parser.add_argument('--time', dest='timestamp', metavar='time', type=float,
help='optional floating-point timestamp in seconds')
parser.add_argument('--verbose', dest='verbose', action='store_true',
help='turns on debug messages in logfile')
parser.add_argument('--version', action='version', version=version)
return parser.parse_args(argv)
def main(argv):
args = parse_args(argv)
level = log.INFO
if args.verbose:
level = log.DEBUG
del args.verbose
if not args.timestamp:
args.timestamp = time.time()
log.basicConfig(filename=os.path.expanduser('~/.wakatime.log'), format='%(asctime)s vim-wakatime/'+version+' %(levelname)s %(message)s', datefmt='%Y-%m-%dT%H:%M:%SZ', level=level)
if os.path.isfile(os.path.realpath(args.task)):
tags = tags_from_path(args.task)
project = project_from_path(args.task)
send_action(project=project, tags=tags, **vars(args))
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))

View file

85
packages/wakatime/log.py Normal file
View file

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""
wakatime.log
~~~~~~~~~~~~
Provides the configured logger for writing JSON to the log file.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
import json
import logging
import os
try:
from collections import OrderedDict
except ImportError:
from .packages.ordereddict import OrderedDict
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
return super(CustomEncoder, self).default(obj)
class JsonFormatter(logging.Formatter):
def __init__(self, timestamp, endtime, isWrite, targetFile, version,
plugin, datefmt=None):
self.timestamp = timestamp
self.endtime = endtime
self.isWrite = isWrite
self.targetFile = targetFile
self.version = version
self.plugin = plugin
super(JsonFormatter, self).__init__(datefmt=datefmt)
def format(self, record):
data = OrderedDict([
('now', self.formatTime(record, self.datefmt)),
('version', self.version),
('plugin', self.plugin),
('time', self.timestamp),
('endtime', self.endtime),
('isWrite', self.isWrite),
('file', self.targetFile),
('level', record.levelname),
('message', record.msg),
])
if not self.endtime:
del data['endtime']
if not self.plugin:
del data['plugin']
if not self.isWrite:
del data['isWrite']
return CustomEncoder().encode(data)
def formatException(self, exc_info):
return exec_info[2].format_exc()
def setup_logging(args, version):
logfile = args.logfile
if not logfile:
logfile = '~/.wakatime.log'
handler = logging.FileHandler(os.path.expanduser(logfile))
formatter = JsonFormatter(
timestamp=args.timestamp,
endtime=args.endtime,
isWrite=args.isWrite,
targetFile=args.targetFile,
version=version,
plugin=args.plugin,
datefmt='%Y-%m-%dT%H:%M:%SZ',
)
handler.setFormatter(formatter)
logger = logging.getLogger()
logger.addHandler(handler)
level = logging.INFO
if args.verbose:
level = logging.DEBUG
logger.setLevel(level)
return logger

View file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,127 @@
# Copyright (c) 2009 Raymond Hettinger
#
# 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 UserDict import DictMixin
class OrderedDict(dict, DictMixin):
def __init__(self, *args, **kwds):
if len(args) > 1:
raise TypeError('expected at most 1 arguments, got %d' % len(args))
try:
self.__end
except AttributeError:
self.clear()
self.update(*args, **kwds)
def clear(self):
self.__end = end = []
end += [None, end, end] # sentinel node for doubly linked list
self.__map = {} # key --> [key, prev, next]
dict.clear(self)
def __setitem__(self, key, value):
if key not in self:
end = self.__end
curr = end[1]
curr[2] = end[1] = self.__map[key] = [key, curr, end]
dict.__setitem__(self, key, value)
def __delitem__(self, key):
dict.__delitem__(self, key)
key, prev, next = self.__map.pop(key)
prev[2] = next
next[1] = prev
def __iter__(self):
end = self.__end
curr = end[2]
while curr is not end:
yield curr[0]
curr = curr[2]
def __reversed__(self):
end = self.__end
curr = end[1]
while curr is not end:
yield curr[0]
curr = curr[1]
def popitem(self, last=True):
if not self:
raise KeyError('dictionary is empty')
if last:
key = reversed(self).next()
else:
key = iter(self).next()
value = self.pop(key)
return key, value
def __reduce__(self):
items = [[k, self[k]] for k in self]
tmp = self.__map, self.__end
del self.__map, self.__end
inst_dict = vars(self).copy()
self.__map, self.__end = tmp
if inst_dict:
return (self.__class__, (items,), inst_dict)
return self.__class__, (items,)
def keys(self):
return list(self)
setdefault = DictMixin.setdefault
update = DictMixin.update
pop = DictMixin.pop
values = DictMixin.values
items = DictMixin.items
iterkeys = DictMixin.iterkeys
itervalues = DictMixin.itervalues
iteritems = DictMixin.iteritems
def __repr__(self):
if not self:
return '%s()' % (self.__class__.__name__,)
return '%s(%r)' % (self.__class__.__name__, self.items())
def copy(self):
return self.__class__(self)
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
def __eq__(self, other):
if isinstance(other, OrderedDict):
if len(self) != len(other):
return False
for p, q in zip(self.items(), other.items()):
if p != q:
return False
return True
return dict.__eq__(self, other)
def __ne__(self, other):
return not self == other

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""
wakatime.project
~~~~~~~~~~~~~~~~
Returns a project for the given file.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
import logging
import os
from .projects.base import BaseProject
from .projects.git import Git
from .projects.mercurial import Mercurial
from .projects.subversion import Subversion
log = logging.getLogger(__name__)
PLUGINS = [
Git,
Mercurial,
Subversion,
]
def find_project(path):
for plugin in PLUGINS:
project = plugin(path)
if project.config:
return project
return BaseProject(path)

View file

View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""
wakatime.projects.base
~~~~~~~~~~~~~~~~~~~~~~
Base project for use when no other project can be found.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
import logging
import os
log = logging.getLogger(__name__)
class BaseProject():
def __init__(self, path):
self.path = path
self.config = self.findConfig(path)
def name(self):
base = self.base()
if base:
return os.path.basename(base)
return None
def type(self):
type = self.__class__.__name__.lower()
if type == 'baseproject':
type = None
return type
def base(self):
if self.config:
return os.path.dirname(self.config)
return None
def tags(self):
tags = []
return tags
def findConfig(self, path):
return ''

View file

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""
wakatime.projects.git
~~~~~~~~~~~~~~~~~~~~~
Information about the git project for a given file.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
import logging
import os
from .base import BaseProject
try:
from collections import OrderedDict
except ImportError:
from ..packages.ordereddict import OrderedDict
log = logging.getLogger(__name__)
class Git(BaseProject):
def base(self):
if self.config:
return os.path.dirname(os.path.dirname(self.config))
return None
def tags(self):
tags = []
if self.config:
sections = self.parseConfig()
for section in sections:
if section.split(' ', 1)[0] == 'remote' and 'url' in sections[section]:
tags.append(sections[section]['url'])
return tags
def findConfig(self, path):
path = os.path.realpath(path)
if os.path.isfile(path):
path = os.path.split(path)[0]
if os.path.isfile(os.path.join(path, '.git', 'config')):
return os.path.join(path, '.git', 'config')
split_path = os.path.split(path)
if split_path[1] == '':
return None
return self.findConfig(split_path[0])
def parseConfig(self):
sections = {}
try:
f = open(self.config, 'r')
except IOError as e:
log.exception("Exception:")
else:
with f:
section = None
for line in f.readlines():
line = line.lstrip()
if len(line) > 0 and line[0] == '[':
section = line[1:].split(']', 1)[0]
temp = section.split(' ', 1)
section = temp[0].lower()
if len(temp) > 1:
section = ' '.join([section, temp[1]])
sections[section] = {}
else:
try:
(setting, value) = line.split('=', 1)
except ValueError:
setting = line.split('#', 1)[0].split(';', 1)[0]
value = 'true'
setting = setting.strip().lower()
value = value.split('#', 1)[0].split(';', 1)[0].strip()
sections[section][setting] = value
f.close()
return sections

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""
wakatime.projects.mercurial
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Information about the mercurial project for a given file.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
import logging
import os
from .base import BaseProject
log = logging.getLogger(__name__)
class Mercurial(BaseProject):
def base(self):
return super(Mercurial, self).base()
def tags(self):
tags = []
return tags

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""
wakatime.projects.subversion
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Information about the svn project for a given file.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
import logging
import os
from .base import BaseProject
log = logging.getLogger(__name__)
class Subversion(BaseProject):
def base(self):
return super(Subversion, self).base()
def tags(self):
tags = []
return tags

View file

@ -0,0 +1,199 @@
# -*- coding: utf-8 -*-
""" wakatime
~~~~~~~~
Action event appender for Wakati.Me, a time tracking api for text editors.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
from __future__ import print_function
__title__ = 'wakatime'
__version__ = '0.1.1'
__author__ = 'Alan Hamlett'
__license__ = 'BSD'
__copyright__ = 'Copyright 2013 Alan Hamlett'
# allow running script directly
if __name__ == '__main__' and __package__ is None:
import os, sys
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, parent_dir)
import wakatime
__package__ = 'wakatime'
del os, sys
import base64
import json
import logging
import os
import platform
import re
import sys
import time
import traceback
import urllib2
from .log import setup_logging
from .project import find_project
try:
import argparse
except ImportError:
from .packages import argparse
log = logging.getLogger(__name__)
class FileAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
values = os.path.realpath(values)
setattr(namespace, self.dest, values)
def parseArguments():
parser = argparse.ArgumentParser(
description='Wakati.Me event api appender')
parser.add_argument('--file', dest='targetFile', metavar='file',
action=FileAction, required=True,
help='absolute path to file for current action')
parser.add_argument('--time', dest='timestamp', metavar='time',
type=float,
help='optional floating-point unix epoch timestamp; '+
'uses current time by default')
parser.add_argument('--endtime', dest='endtime',
help='optional end timestamp turning this action into '+
'a duration; if a non-duration action occurs within a '+
'duration, the duration is ignored')
parser.add_argument('--write', dest='isWrite',
action='store_true',
help='note action was triggered from writing to a file')
parser.add_argument('--plugin', dest='plugin',
help='optional text editor plugin name and version '+
'for User-Agent header')
parser.add_argument('--key', dest='key',
help='your wakati.me api key; uses api_key from '+
'~/.wakatime.conf by default')
parser.add_argument('--logfile', dest='logfile',
help='defaults to ~/.wakatime.log')
parser.add_argument('--config', dest='config',
help='defaults to ~/.wakatime.conf')
parser.add_argument('--verbose', dest='verbose', action='store_true',
help='turns on debug messages in log file')
parser.add_argument('--version', action='version', version=__version__)
args = parser.parse_args()
if not args.timestamp:
args.timestamp = time.time()
if not args.key:
default_key = get_api_key(args.config)
if default_key:
args.key = default_key
else:
parser.error('Missing api key')
if args.endtime and args.endtime < args.timestamp:
tmp = args.timestamp
args.timestamp = args.endtime
args.endtime = tmp
return args
def get_api_key(configFile):
if not configFile:
configFile = '~/.wakatime.conf'
api_key = None
try:
cf = open(os.path.expanduser(configFile))
for line in cf:
line = line.split('=', 1)
if line[0] == 'api_key':
api_key = line[1].strip()
cf.close()
except IOError:
print('Error: Could not read from config file.')
return api_key
def get_user_agent(plugin):
user_agent = 'wakatime/%s (%s)' % (__version__, platform.platform())
if plugin:
user_agent = user_agent+' '+plugin
return user_agent
def send_action(project=None, tags=None, key=None, targetFile=None,
timestamp=None, endtime=None, isWrite=None, plugin=None, **kwargs):
url = 'https://www.wakati.me/api/v1/actions'
log.debug('Sending action to api at %s' % url)
data = {
'time': timestamp,
'file': targetFile,
}
if endtime:
data['endtime'] = endtime
if isWrite:
data['isWrite'] = isWrite
if project:
data['project'] = project
if tags:
data['tags'] = tags
log.debug(data)
request = urllib2.Request(url=url, data=json.dumps(data))
user_agent = get_user_agent(plugin)
request.add_header('User-Agent', user_agent)
request.add_header('Content-Type', 'application/json')
request.add_header('Authorization', 'Basic %s' % base64.b64encode(key))
response = None
try:
response = urllib2.urlopen(request)
except urllib2.HTTPError as exc:
data = {
'response_code': exc.getcode(),
'response_content': exc.read(),
sys.exc_info()[0].__name__: str(sys.exc_info()[1]),
}
if log.isEnabledFor(logging.DEBUG):
data['traceback'] = traceback.format_exc()
log.error(data)
except:
data = {
sys.exc_info()[0].__name__: str(sys.exc_info()[1]),
}
if log.isEnabledFor(logging.DEBUG):
data['traceback'] = traceback.format_exc()
log.error(data)
else:
if response.getcode() >= 200 and response.getcode() < 300:
log.debug({
'response_code': response.getcode(),
'response_content': response.read(),
})
return True
log.error({
'response_code': response.getcode(),
'response_content': response.read(),
})
return False
def main():
args = parseArguments()
setup_logging(args, __version__)
if os.path.isfile(args.targetFile):
project = find_project(args.targetFile)
tags = project.tags()
if send_action(project=project.name(), tags=tags, **vars(args)):
return 0
return 102
else:
log.debug('File does not exist; ignoring this action.')
return 101
if __name__ == '__main__':
sys.exit(main())

View file

@ -1,10 +1,11 @@
""" ======================================================
""" ==========================================================
File: sublime-wakatime.py
Description: Automatic time tracking for Sublime Text 2.
Description: Automatic time tracking for Sublime Text 2 and 3.
Maintainer: Wakati.Me <support@wakatime.com>
Version: 0.1.0
======================================================="""
Website: https://www.wakati.me/
==========================================================="""
__version__ = '0.2.1'
import time
import uuid
@ -15,52 +16,77 @@ import sublime
import sublime_plugin
# Create logfile if does not exist
call(['touch', '~/.wakatime.log'])
# Prompt user if no activity for this many minutes
AWAY_MINUTES = 10
# globals
PLUGIN_DIR = dirname(realpath(__file__))
API_CLIENT = '%s/libs/wakatime.py' % PLUGIN_DIR
INSTANCE_ID = str(uuid.uuid4())
API_CLIENT = '%s/packages/wakatime/wakatime.py' % PLUGIN_DIR
LAST_ACTION = 0
LAST_FILE = None
def get_api_key():
api_key = None
try:
cf = open(expanduser('~/.wakatime'))
for line in cf:
line = line.split('=', 1)
if line[0] == 'api_key':
api_key = line[1].strip()
cf.close()
except IOError:
pass
return api_key
def api(targetFile, timestamp, isWrite=False, endtime=None):
global LAST_ACTION, LAST_FILE
if not targetFile:
targetFile = LAST_FILE
if targetFile:
cmd = ['python', API_CLIENT,
'--file', targetFile,
'--time', str('%f' % timestamp),
'--plugin', 'sublime-wakatime/%s' % __version__,
]
if isWrite:
cmd.append('--write')
if endtime:
cmd.extend(['--endtime', str('%f' % endtime)])
Popen(cmd)
LAST_ACTION = timestamp
if endtime and endtime > LAST_ACTION:
LAST_ACTION = endtime
LAST_FILE = targetFile
def api(action, task, timestamp):
if task:
api_key = get_api_key()
if api_key:
cmd = ['python', API_CLIENT,
'--key', api_key,
'--instance', INSTANCE_ID,
'--action', action,
'--task', task,
'--time', str('%f' % timestamp)]
Popen(cmd)
def away(now):
if LAST_ACTION == 0:
return False
duration = now - LAST_ACTION
if duration > AWAY_MINUTES * 60:
duration = int(duration)
units = 'seconds'
if duration > 60:
duration = int(duration / 60.0)
units = 'minutes'
if duration > 60:
duration = int(duration / 60.0)
units = 'hours'
return sublime\
.ok_cancel_dialog("You were away %d %s. Add time to current file?"\
% (duration, units), 'Yes, log this time')
def enough_time_passed(now):
return (now - LAST_ACTION >= 5 * 60)
class WakatimeListener(sublime_plugin.EventListener):
def on_post_save(self, view):
api('write_file', view.file_name(), time.time())
api(view.file_name(), time.time(), isWrite=True)
def on_activated(self, view):
api('open_file', view.file_name(), time.time())
now = time.time()
if enough_time_passed(now):
if away(now):
api(view.file_name(), LAST_ACTION, endtime=now)
else:
api(view.file_name(), now)
def on_deactivated(self, view):
api('close_file', view.file_name(), time.time())
def on_selection_modified(self, view):
now = time.time()
if enough_time_passed(now):
if away(now):
api(view.file_name(), LAST_ACTION, endtime=now)
else:
api(view.file_name(), now)
if get_api_key() is None:
sublime.error_message('Missing your Wakati.Me api key')