sublime-wakatime/packages/wakatime/main.py

504 lines
18 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
2015-09-29 13:11:25 +03:00
wakatime.main
2015-03-10 01:23:29 +03:00
~~~~~~~~~~~~~
2015-03-10 01:23:29 +03:00
wakatime module entry point.
:copyright: (c) 2013 Alan Hamlett.
:license: BSD, see LICENSE for more details.
"""
from __future__ import print_function
import base64
import logging
import os
import platform
import re
import sys
import time
import traceback
import socket
try:
import ConfigParser as configparser
2015-08-25 10:42:37 +03:00
except ImportError: # pragma: nocover
import configparser
2016-03-06 23:48:42 +03:00
pwd = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(pwd))
sys.path.insert(0, os.path.join(pwd, 'packages'))
2014-06-10 00:18:30 +04:00
2015-03-10 01:23:29 +03:00
from .__about__ import __version__
2014-12-23 14:39:07 +03:00
from .compat import u, open, is_py3
2016-03-06 23:48:42 +03:00
from .constants import (
API_ERROR,
AUTH_ERROR,
CONFIG_FILE_PARSE_ERROR,
SUCCESS,
2016-03-07 01:13:27 +03:00
UNKNOWN_ERROR,
2016-03-06 23:48:42 +03:00
)
2015-04-04 21:03:55 +03:00
from .logger import setup_logging
2015-05-13 01:03:23 +03:00
from .offlinequeue import Queue
from .packages import argparse
2016-01-11 22:27:56 +03:00
from .packages import requests
2015-03-10 01:23:29 +03:00
from .packages.requests.exceptions import RequestException
2015-06-30 05:47:04 +03:00
from .project import get_project_info
2015-05-13 01:03:23 +03:00
from .session_cache import SessionCache
from .stats import get_file_stats
2014-06-10 00:18:30 +04:00
try:
2015-08-25 10:42:37 +03:00
from .packages import simplejson as json # pragma: nocover
2015-09-29 13:11:25 +03:00
except (ImportError, SyntaxError): # pragma: nocover
import json
2015-08-25 10:42:37 +03:00
try:
2015-09-29 13:11:25 +03:00
from .packages import tzlocal
2015-08-25 10:42:37 +03:00
except: # pragma: nocover
2015-09-29 13:11:25 +03:00
from .packages import tzlocal3 as tzlocal
log = logging.getLogger('WakaTime')
class FileAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
2015-09-29 13:11:25 +03:00
try:
if os.path.isfile(values):
values = os.path.realpath(values)
except: # pragma: nocover
pass
setattr(namespace, self.dest, values)
def parseConfigFile(configFile=None):
"""Returns a configparser.SafeConfigParser instance with configs
read from the config file. Default location of the config file is
at ~/.wakatime.cfg.
"""
if not configFile:
configFile = os.path.join(os.path.expanduser('~'), '.wakatime.cfg')
configs = configparser.SafeConfigParser()
try:
2014-09-30 20:27:35 +04:00
with open(configFile, 'r', encoding='utf-8') as fh:
try:
configs.readfp(fh)
except configparser.Error:
print(traceback.format_exc())
return None
except IOError:
print(u('Error: Could not read from config file {0}').format(u(configFile)))
return configs
2015-08-25 10:42:37 +03:00
def parseArguments():
"""Parse command line arguments and configs from ~/.wakatime.cfg.
Command line arguments take precedence over config file settings.
Returns instances of ArgumentParser and SafeConfigParser.
"""
# define supported command line arguments
parser = argparse.ArgumentParser(
2014-01-14 17:11:00 +04:00
description='Common interface for the WakaTime api.')
2015-09-29 13:11:25 +03:00
parser.add_argument('--entity', dest='entity', metavar='FILE',
action=FileAction,
help='absolute path to file for the heartbeat; can also be a '+
'url, domain, or app when --entitytype is not file')
parser.add_argument('--file', dest='file', action=FileAction,
help=argparse.SUPPRESS)
2015-03-10 01:23:29 +03:00
parser.add_argument('--key', dest='key',
help='your wakatime api key; uses api_key from '+
'~/.wakatime.conf by default')
parser.add_argument('--write', dest='isWrite',
action='store_true',
2015-03-10 01:23:29 +03:00
help='when set, tells api this heartbeat was triggered from '+
'writing to a file')
parser.add_argument('--plugin', dest='plugin',
help='optional text editor plugin name and version '+
2015-03-10 01:23:29 +03:00
'for User-Agent header')
parser.add_argument('--time', dest='timestamp', metavar='time',
type=float,
help='optional floating-point unix epoch timestamp; '+
'uses current time by default')
2015-05-07 01:45:34 +03:00
parser.add_argument('--lineno', dest='lineno',
help='optional line number; current line being edited')
2015-05-07 02:33:32 +03:00
parser.add_argument('--cursorpos', dest='cursorpos',
help='optional cursor position in the current file')
2015-09-29 13:11:25 +03:00
parser.add_argument('--entitytype', dest='entity_type',
help='entity type for this heartbeat. can be one of "file", '+
'"url", "domain", or "app"; defaults to file.')
2015-03-10 01:23:29 +03:00
parser.add_argument('--proxy', dest='proxy',
help='optional https proxy url; for example: '+
2016-03-06 23:48:42 +03:00
'https://user:pass@localhost:8080')
2015-05-16 01:33:03 +03:00
parser.add_argument('--project', dest='project',
help='optional project name')
parser.add_argument('--alternate-project', dest='alternate_project',
2016-03-06 23:48:42 +03:00
help='optional alternate project name; auto-discovered project '+
'takes priority')
parser.add_argument('--hostname', dest='hostname', help='hostname of '+
'current machine.')
parser.add_argument('--disableoffline', dest='offline',
action='store_false',
help='disables offline time logging instead of queuing logged time')
2014-07-25 13:32:01 +04:00
parser.add_argument('--hidefilenames', dest='hidefilenames',
action='store_true',
help='obfuscate file names; will not send file names to api')
2015-03-10 01:23:29 +03:00
parser.add_argument('--exclude', dest='exclude', action='append',
help='filename patterns to exclude from logging; POSIX regex '+
'syntax; can be used more than once')
parser.add_argument('--include', dest='include', action='append',
help='filename patterns to log; when used in combination with '+
'--exclude, files matching include will still be logged; '+
'POSIX regex syntax; can be used more than once')
parser.add_argument('--ignore', dest='ignore', action='append',
2015-03-10 01:23:29 +03:00
help=argparse.SUPPRESS)
parser.add_argument('--logfile', dest='logfile',
help='defaults to ~/.wakatime.log')
2015-04-04 21:03:55 +03:00
parser.add_argument('--apiurl', dest='api_url',
help='heartbeats api url; for debugging with a local server')
2015-09-29 13:11:25 +03:00
parser.add_argument('--timeout', dest='timeout', type=int,
help='number of seconds to wait when sending heartbeats to api')
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__)
# parse command line arguments
2015-08-25 10:42:37 +03:00
args = parser.parse_args()
# use current unix epoch timestamp by default
if not args.timestamp:
args.timestamp = time.time()
# parse ~/.wakatime.cfg file
configs = parseConfigFile(args.config)
if configs is None:
return args, configs
# update args from configs
if not args.key:
default_key = None
if configs.has_option('settings', 'api_key'):
default_key = configs.get('settings', 'api_key')
2015-03-10 01:23:29 +03:00
elif configs.has_option('settings', 'apikey'):
default_key = configs.get('settings', 'apikey')
if default_key:
args.key = default_key
else:
parser.error('Missing api key')
2015-09-29 13:11:25 +03:00
if not args.entity_type:
args.entity_type = 'file'
if not args.entity:
if args.file:
args.entity = args.file
else:
parser.error('argument --entity is required')
2015-03-10 01:23:29 +03:00
if not args.exclude:
args.exclude = []
if configs.has_option('settings', 'ignore'):
try:
for pattern in configs.get('settings', 'ignore').split("\n"):
if pattern.strip() != '':
2015-03-10 01:23:29 +03:00
args.exclude.append(pattern)
2015-09-29 13:11:25 +03:00
except TypeError: # pragma: nocover
2015-03-10 01:23:29 +03:00
pass
if configs.has_option('settings', 'exclude'):
try:
for pattern in configs.get('settings', 'exclude').split("\n"):
if pattern.strip() != '':
args.exclude.append(pattern)
2015-09-29 13:11:25 +03:00
except TypeError: # pragma: nocover
2015-03-10 01:23:29 +03:00
pass
if not args.include:
args.include = []
if configs.has_option('settings', 'include'):
try:
for pattern in configs.get('settings', 'include').split("\n"):
if pattern.strip() != '':
args.include.append(pattern)
2015-09-29 13:11:25 +03:00
except TypeError: # pragma: nocover
pass
if args.offline and configs.has_option('settings', 'offline'):
args.offline = configs.getboolean('settings', 'offline')
2014-07-25 13:32:01 +04:00
if not args.hidefilenames and configs.has_option('settings', 'hidefilenames'):
args.hidefilenames = configs.getboolean('settings', 'hidefilenames')
2015-03-10 01:23:29 +03:00
if not args.proxy and configs.has_option('settings', 'proxy'):
args.proxy = configs.get('settings', 'proxy')
if not args.verbose and configs.has_option('settings', 'verbose'):
args.verbose = configs.getboolean('settings', 'verbose')
if not args.verbose and configs.has_option('settings', 'debug'):
args.verbose = configs.getboolean('settings', 'debug')
if not args.logfile and configs.has_option('settings', 'logfile'):
args.logfile = configs.get('settings', 'logfile')
2015-04-04 21:03:55 +03:00
if not args.api_url and configs.has_option('settings', 'api_url'):
args.api_url = configs.get('settings', 'api_url')
2015-09-29 13:11:25 +03:00
if not args.timeout and configs.has_option('settings', 'timeout'):
try:
args.timeout = int(configs.get('settings', 'timeout'))
except ValueError:
print(traceback.format_exc())
return args, configs
2015-09-29 13:11:25 +03:00
def should_exclude(entity, include, exclude):
if entity is not None and entity.strip() != '':
2015-03-10 01:23:29 +03:00
try:
for pattern in include:
try:
compiled = re.compile(pattern, re.IGNORECASE)
2015-09-29 13:11:25 +03:00
if compiled.search(entity):
2015-03-10 01:23:29 +03:00
return False
except re.error as ex:
log.warning(u('Regex error ({msg}) for include pattern: {pattern}').format(
msg=u(ex),
pattern=u(pattern),
))
2015-08-25 10:42:37 +03:00
except TypeError: # pragma: nocover
2015-03-10 01:23:29 +03:00
pass
try:
for pattern in exclude:
try:
compiled = re.compile(pattern, re.IGNORECASE)
2015-09-29 13:11:25 +03:00
if compiled.search(entity):
2015-03-10 01:23:29 +03:00
return pattern
except re.error as ex:
log.warning(u('Regex error ({msg}) for exclude pattern: {pattern}').format(
msg=u(ex),
pattern=u(pattern),
))
2015-08-25 10:42:37 +03:00
except TypeError: # pragma: nocover
2015-03-10 01:23:29 +03:00
pass
return False
def get_user_agent(plugin):
2013-07-24 07:33:30 +04:00
ver = sys.version_info
2013-07-31 04:21:27 +04:00
python_version = '%d.%d.%d.%s.%d' % (ver[0], ver[1], ver[2], ver[3], ver[4])
2014-09-30 20:27:35 +04:00
user_agent = u('wakatime/{ver} ({platform}) Python{py_ver}').format(
ver=u(__version__),
platform=u(platform.platform()),
2014-07-25 13:32:01 +04:00
py_ver=python_version,
)
if plugin:
2014-09-30 20:27:35 +04:00
user_agent = u('{user_agent} {plugin}').format(
2014-07-25 13:32:01 +04:00
user_agent=user_agent,
plugin=u(plugin),
2014-07-25 13:32:01 +04:00
)
2015-03-10 01:23:29 +03:00
else:
user_agent = u('{user_agent} Unknown/0').format(
user_agent=user_agent,
)
return user_agent
2016-03-06 23:48:42 +03:00
def send_heartbeat(project=None, branch=None, hostname=None, stats={}, key=None,
entity=None, timestamp=None, isWrite=None, plugin=None,
offline=None, entity_type='file', hidefilenames=None,
proxy=None, api_url=None, timeout=None, **kwargs):
2015-04-04 21:03:55 +03:00
"""Sends heartbeat as POST request to WakaTime api server.
2016-03-06 23:48:42 +03:00
Returns `SUCCESS` when heartbeat was sent, otherwise returns an
error code constant.
2015-04-04 21:03:55 +03:00
"""
if not api_url:
2016-01-07 00:57:03 +03:00
api_url = 'https://api.wakatime.com/api/v1/heartbeats'
2015-09-29 13:11:25 +03:00
if not timeout:
timeout = 30
2015-04-04 21:03:55 +03:00
log.debug('Sending heartbeat to api at %s' % api_url)
data = {
'time': timestamp,
2015-09-29 13:11:25 +03:00
'entity': entity,
'type': entity_type,
}
2015-09-29 13:11:25 +03:00
if hidefilenames and entity is not None and entity_type == 'file':
2015-08-25 10:42:37 +03:00
extension = u(os.path.splitext(data['entity'])[1])
data['entity'] = u('HIDDEN{0}').format(extension)
2013-09-23 00:51:23 +04:00
if stats.get('lines'):
data['lines'] = stats['lines']
if stats.get('language'):
data['language'] = stats['language']
2014-12-22 10:01:09 +03:00
if stats.get('dependencies'):
data['dependencies'] = stats['dependencies']
2015-05-07 01:45:34 +03:00
if stats.get('lineno'):
data['lineno'] = stats['lineno']
2015-05-07 02:33:32 +03:00
if stats.get('cursorpos'):
data['cursorpos'] = stats['cursorpos']
if isWrite:
data['is_write'] = isWrite
if project:
data['project'] = project
if branch:
data['branch'] = branch
log.debug(data)
# setup api request
2014-09-30 20:27:35 +04:00
request_body = json.dumps(data)
2015-03-10 01:23:29 +03:00
api_key = u(base64.b64encode(str.encode(key) if is_py3 else key))
auth = u('Basic {api_key}').format(api_key=api_key)
headers = {
'User-Agent': get_user_agent(plugin),
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': auth,
}
if hostname:
2016-03-07 01:13:27 +03:00
headers['X-Machine-Name'] = u(hostname).encode('utf-8')
2015-03-10 01:23:29 +03:00
proxies = {}
if proxy:
proxies['https'] = proxy
2014-12-01 09:21:46 +03:00
2013-10-01 08:57:30 +04:00
# add Olson timezone to request
try:
tz = tzlocal.get_localzone()
except:
tz = None
2013-10-01 08:57:30 +04:00
if tz:
2016-03-07 01:13:27 +03:00
headers['TimeZone'] = u(tz.zone).encode('utf-8')
2013-10-01 08:57:30 +04:00
2015-05-13 01:03:23 +03:00
session_cache = SessionCache()
session = session_cache.get()
# log time to api
2013-09-07 09:50:12 +04:00
response = None
try:
2015-05-13 01:03:23 +03:00
response = session.post(api_url, data=request_body, headers=headers,
2016-03-06 23:48:42 +03:00
proxies=proxies, timeout=timeout)
2015-03-10 01:23:29 +03:00
except RequestException:
exception_data = {
2014-09-30 20:27:35 +04:00
sys.exc_info()[0].__name__: u(sys.exc_info()[1]),
2013-09-07 09:50:12 +04:00
}
if log.isEnabledFor(logging.DEBUG):
exception_data['traceback'] = traceback.format_exc()
if offline:
2015-03-10 01:23:29 +03:00
queue = Queue()
queue.push(data, json.dumps(stats), plugin)
if log.isEnabledFor(logging.DEBUG):
log.warn(exception_data)
else:
log.error(exception_data)
else:
2016-03-06 23:48:42 +03:00
code = response.status_code if response is not None else None
content = response.text if response is not None else None
if code == requests.codes.created or code == requests.codes.accepted:
log.debug({
2016-03-06 23:48:42 +03:00
'response_code': code,
})
2015-05-13 01:03:23 +03:00
session_cache.save(session)
2016-03-06 23:48:42 +03:00
return SUCCESS
if offline:
2016-03-06 23:48:42 +03:00
if code != 400:
2014-12-22 10:01:09 +03:00
queue = Queue()
queue.push(data, json.dumps(stats), plugin)
2016-03-06 23:48:42 +03:00
if code == 401:
2015-03-10 01:23:29 +03:00
log.error({
2016-03-06 23:48:42 +03:00
'response_code': code,
'response_content': content,
2015-03-10 01:23:29 +03:00
})
2016-03-06 23:48:42 +03:00
session_cache.delete()
return AUTH_ERROR
2015-03-10 01:23:29 +03:00
elif log.isEnabledFor(logging.DEBUG):
log.warn({
2016-03-06 23:48:42 +03:00
'response_code': code,
'response_content': content,
2015-03-10 01:23:29 +03:00
})
else:
log.error({
2016-03-06 23:48:42 +03:00
'response_code': code,
'response_content': content,
})
else:
log.error({
2016-03-06 23:48:42 +03:00
'response_code': code,
'response_content': content,
})
2015-05-13 01:03:23 +03:00
session_cache.delete()
2016-03-06 23:48:42 +03:00
return API_ERROR
def sync_offline_heartbeats(args, hostname):
"""Sends all heartbeats which were cached in the offline Queue."""
queue = Queue()
while True:
heartbeat = queue.pop()
if heartbeat is None:
break
status = send_heartbeat(
project=heartbeat['project'],
entity=heartbeat['entity'],
timestamp=heartbeat['time'],
branch=heartbeat['branch'],
hostname=hostname,
stats=json.loads(heartbeat['stats']),
key=args.key,
isWrite=heartbeat['is_write'],
plugin=heartbeat['plugin'],
offline=args.offline,
hidefilenames=args.hidefilenames,
entity_type=heartbeat['type'],
proxy=args.proxy,
api_url=args.api_url,
timeout=args.timeout,
)
if status != SUCCESS:
if status == AUTH_ERROR:
return AUTH_ERROR
break
return SUCCESS
2015-09-29 13:11:25 +03:00
def execute(argv=None):
if argv:
sys.argv = ['wakatime'] + argv
2015-08-25 10:42:37 +03:00
args, configs = parseArguments()
if configs is None:
2016-01-07 00:57:03 +03:00
return CONFIG_FILE_PARSE_ERROR
setup_logging(args, __version__)
2016-01-07 00:57:03 +03:00
try:
exclude = should_exclude(args.entity, args.include, args.exclude)
if exclude is not False:
log.debug(u('Skipping because matches exclude pattern: {pattern}').format(
pattern=u(exclude),
))
return SUCCESS
if args.entity_type != 'file' or os.path.isfile(args.entity):
stats = get_file_stats(args.entity,
entity_type=args.entity_type,
lineno=args.lineno,
cursorpos=args.cursorpos)
project = args.project or args.alternate_project
branch = None
if args.entity_type == 'file':
project, branch = get_project_info(configs, args)
kwargs = vars(args)
kwargs['project'] = project
kwargs['branch'] = branch
kwargs['stats'] = stats
2016-03-06 23:48:42 +03:00
hostname = args.hostname or socket.gethostname()
kwargs['hostname'] = hostname
2016-01-07 00:57:03 +03:00
kwargs['timeout'] = args.timeout
2016-03-06 23:48:42 +03:00
status = send_heartbeat(**kwargs)
if status == SUCCESS:
return sync_offline_heartbeats(args, hostname)
else:
return status
2016-01-07 00:57:03 +03:00
else:
log.debug('File does not exist; ignoring this heartbeat.')
return SUCCESS
except:
log.traceback()
2016-03-07 01:13:27 +03:00
return UNKNOWN_ERROR