# -*- coding: utf-8 -*- """ wakatime.main ~~~~~~~~~~~~~ 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 except ImportError: # pragma: nocover import configparser 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')) from .__about__ import __version__ from .compat import u, open, is_py3 from .constants import ( API_ERROR, AUTH_ERROR, CONFIG_FILE_PARSE_ERROR, SUCCESS, UNKNOWN_ERROR, MALFORMED_HEARTBEAT_ERROR, ) from .logger import setup_logging from .offlinequeue import Queue from .packages import argparse from .packages import requests from .packages.requests.exceptions import RequestException from .project import get_project_info from .session_cache import SessionCache from .stats import get_file_stats try: from .packages import simplejson as json # pragma: nocover except (ImportError, SyntaxError): # pragma: nocover import json from .packages import tzlocal log = logging.getLogger('WakaTime') class FileAction(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): 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. """ # get config file location from ENV home = os.environ.get('WAKATIME_HOME') if not configFile and home: configFile = os.path.join(os.path.expanduser(home), '.wakatime.cfg') # use default config file location if not configFile: configFile = os.path.join(os.path.expanduser('~'), '.wakatime.cfg') configs = configparser.SafeConfigParser() try: with open(configFile, 'r', encoding='utf-8') as fh: try: configs.readfp(fh) except configparser.Error: print(traceback.format_exc()) return None except IOError: sys.stderr.write(u("Error: Could not read from config file {0}\n").format(u(configFile))) raise SystemExit(CONFIG_FILE_PARSE_ERROR) return configs 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( description='Common interface for the WakaTime api.') 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 --entity-type is not file') parser.add_argument('--file', dest='file', action=FileAction, help=argparse.SUPPRESS) parser.add_argument('--key', dest='key', help='your wakatime api key; uses api_key from '+ '~/.wakatime.cfg by default') parser.add_argument('--write', dest='is_write', action='store_true', 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 '+ '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') parser.add_argument('--lineno', dest='lineno', help='optional line number; current line being edited') parser.add_argument('--cursorpos', dest='cursorpos', help='optional cursor position in the current file') parser.add_argument('--entity-type', dest='entity_type', help='entity type for this heartbeat. can be one of "file", '+ '"domain", or "app"; defaults to file.') parser.add_argument('--proxy', dest='proxy', help='optional proxy configuration. Supports HTTPS '+ 'and SOCKS proxies. For example: '+ 'https://user:pass@host:port or '+ 'socks5://user:pass@host:port or ' + 'domain\\user:pass') parser.add_argument('--project', dest='project', help='optional project name') parser.add_argument('--alternate-project', dest='alternate_project', help='optional alternate project name; auto-discovered project '+ 'takes priority') parser.add_argument('--alternate-language', dest='alternate_language', help='optional alternate language name; auto-detected language'+ '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') parser.add_argument('--hidefilenames', dest='hidefilenames', action='store_true', help='obfuscate file names; will not send file names to api') 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', help=argparse.SUPPRESS) parser.add_argument('--extra-heartbeats', dest='extra_heartbeats', action='store_true', help='reads extra heartbeats from STDIN as a JSON array until EOF') parser.add_argument('--logfile', dest='logfile', help='defaults to ~/.wakatime.log') parser.add_argument('--apiurl', dest='api_url', help='heartbeats api url; for debugging with a local server') parser.add_argument('--timeout', dest='timeout', type=int, help='number of seconds to wait when sending heartbeats to api; '+ 'defaults to 60 seconds') parser.add_argument('--config', dest='config', help='defaults to ~/.wakatime.cfg') 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 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.hostname: if configs.has_option('settings', 'hostname'): args.hostname = configs.get('settings', 'hostname') if not args.key: default_key = None if configs.has_option('settings', 'api_key'): default_key = configs.get('settings', 'api_key') elif configs.has_option('settings', 'apikey'): default_key = configs.get('settings', 'apikey') if default_key: args.key = default_key else: try: parser.error('Missing api key. Find your api key from wakatime.com/settings.') except SystemExit: raise SystemExit(AUTH_ERROR) is_valid = not not re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$', args.key, re.I) if not is_valid: try: parser.error('Invalid api key. Find your api key from wakatime.com/settings.') except SystemExit: raise SystemExit(AUTH_ERROR) if not args.entity: if args.file: args.entity = args.file else: parser.error('argument --entity is required') 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() != '': args.exclude.append(pattern) except TypeError: # pragma: nocover pass if configs.has_option('settings', 'exclude'): try: for pattern in configs.get('settings', 'exclude').split("\n"): if pattern.strip() != '': args.exclude.append(pattern) except TypeError: # pragma: nocover 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) except TypeError: # pragma: nocover pass if args.hidefilenames: args.hidefilenames = ['.*'] else: args.hidefilenames = [] if configs.has_option('settings', 'hidefilenames'): option = configs.get('settings', 'hidefilenames') if option.strip().lower() == 'true': args.hidefilenames = ['.*'] elif option.strip().lower() != 'false': for pattern in option.split("\n"): if pattern.strip() != '': args.hidefilenames.append(pattern) if args.offline and configs.has_option('settings', 'offline'): args.offline = configs.getboolean('settings', 'offline') if not args.proxy and configs.has_option('settings', 'proxy'): args.proxy = configs.get('settings', 'proxy') if args.proxy: pattern = r'^((https?|socks5)://)?([^:@]+(:([^:@])+)?@)?[^:]+(:\d+)?$' if '\\' in args.proxy: pattern = r'^.*\\.+$' is_valid = not not re.match(pattern, args.proxy, re.I) if not is_valid: parser.error('Invalid proxy. Must be in format ' + 'https://user:pass@host:port or ' + 'socks5://user:pass@host:port or ' + 'domain\\user:pass.') 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') if not args.logfile and os.environ.get('WAKATIME_HOME'): home = os.environ.get('WAKATIME_HOME') args.logfile = os.path.join(os.path.expanduser(home), '.wakatime.log') if not args.api_url and configs.has_option('settings', 'api_url'): args.api_url = configs.get('settings', 'api_url') 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 def should_exclude(entity, include, exclude): if entity is not None and entity.strip() != '': for pattern in include: try: compiled = re.compile(pattern, re.IGNORECASE) if compiled.search(entity): return False except re.error as ex: log.warning(u('Regex error ({msg}) for include pattern: {pattern}').format( msg=u(ex), pattern=u(pattern), )) for pattern in exclude: try: compiled = re.compile(pattern, re.IGNORECASE) if compiled.search(entity): return pattern except re.error as ex: log.warning(u('Regex error ({msg}) for exclude pattern: {pattern}').format( msg=u(ex), pattern=u(pattern), )) return False def get_user_agent(plugin): ver = sys.version_info python_version = '%d.%d.%d.%s.%d' % (ver[0], ver[1], ver[2], ver[3], ver[4]) user_agent = u('wakatime/{ver} ({platform}) Python{py_ver}').format( ver=u(__version__), platform=u(platform.platform()), py_ver=python_version, ) if plugin: user_agent = u('{user_agent} {plugin}').format( user_agent=user_agent, plugin=u(plugin), ) else: user_agent = u('{user_agent} Unknown/0').format( user_agent=user_agent, ) return user_agent def send_heartbeat(project=None, branch=None, hostname=None, stats={}, key=None, entity=None, timestamp=None, is_write=None, plugin=None, offline=None, entity_type='file', hidefilenames=None, proxy=None, api_url=None, timeout=None, **kwargs): """Sends heartbeat as POST request to WakaTime api server. Returns `SUCCESS` when heartbeat was sent, otherwise returns an error code constant. """ if not api_url: api_url = 'https://api.wakatime.com/api/v1/heartbeats' if not timeout: timeout = 60 log.debug('Sending heartbeat to api at %s' % api_url) data = { 'time': timestamp, 'entity': entity, 'type': entity_type, } if hidefilenames and entity is not None and entity_type == 'file': for pattern in hidefilenames: try: compiled = re.compile(pattern, re.IGNORECASE) if compiled.search(entity): extension = u(os.path.splitext(data['entity'])[1]) data['entity'] = u('HIDDEN{0}').format(extension) break except re.error as ex: log.warning(u('Regex error ({msg}) for include pattern: {pattern}').format( msg=u(ex), pattern=u(pattern), )) if stats.get('lines'): data['lines'] = stats['lines'] if stats.get('language'): data['language'] = stats['language'] if stats.get('dependencies'): data['dependencies'] = stats['dependencies'] if stats.get('lineno'): data['lineno'] = stats['lineno'] if stats.get('cursorpos'): data['cursorpos'] = stats['cursorpos'] if is_write: data['is_write'] = is_write if project: data['project'] = project if branch: data['branch'] = branch log.debug(data) # setup api request request_body = json.dumps(data) 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: headers['X-Machine-Name'] = u(hostname).encode('utf-8') # add Olson timezone to request try: tz = tzlocal.get_localzone() except: tz = None if tz: headers['TimeZone'] = u(tz.zone).encode('utf-8') session_cache = SessionCache() session = session_cache.get() proxies = {} if proxy: if '\\' in proxy: from .packages.requests_ntlm import HttpNtlmAuth username = proxy.rsplit(':', 1) password = '' if len(username) == 2: password = username[1] username = username[0] session.auth = HttpNtlmAuth(username, password, session) else: proxies['https'] = proxy # log time to api response = None try: response = session.post(api_url, data=request_body, headers=headers, proxies=proxies, timeout=timeout) except RequestException: exception_data = { sys.exc_info()[0].__name__: u(sys.exc_info()[1]), } if log.isEnabledFor(logging.DEBUG): exception_data['traceback'] = traceback.format_exc() if offline: queue = Queue() queue.push(data, json.dumps(stats), plugin) if log.isEnabledFor(logging.DEBUG): log.warn(exception_data) else: log.error(exception_data) except: # delete cached session when requests raises unknown exception exception_data = { sys.exc_info()[0].__name__: u(sys.exc_info()[1]), 'traceback': traceback.format_exc(), } if offline: queue = Queue() queue.push(data, json.dumps(stats), plugin) log.warn(exception_data) session_cache.delete() else: 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({ 'response_code': code, }) session_cache.save(session) return SUCCESS if offline: if code != 400: queue = Queue() queue.push(data, json.dumps(stats), plugin) if code == 401: log.error({ 'response_code': code, 'response_content': content, }) session_cache.delete() return AUTH_ERROR elif log.isEnabledFor(logging.DEBUG): log.warn({ 'response_code': code, 'response_content': content, }) else: log.error({ 'response_code': code, 'response_content': content, }) else: log.error({ 'response_code': code, 'response_content': content, }) session_cache.delete() 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, is_write=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 def format_file_path(filepath): """Formats a path as absolute and with the correct platform separator.""" try: filepath = os.path.realpath(os.path.abspath(filepath)) filepath = re.sub(r'[/\\]', os.path.sep, filepath) except: # pragma: nocover pass return filepath def process_heartbeat(args, configs, hostname, heartbeat): exclude = should_exclude(heartbeat['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 heartbeat.get('entity_type') not in ['file', 'domain', 'app']: heartbeat['entity_type'] = 'file' if heartbeat['entity_type'] == 'file': heartbeat['entity'] = format_file_path(heartbeat['entity']) if heartbeat['entity_type'] != 'file' or os.path.isfile(heartbeat['entity']): stats = get_file_stats(heartbeat['entity'], entity_type=heartbeat['entity_type'], lineno=heartbeat.get('lineno'), cursorpos=heartbeat.get('cursorpos'), plugin=args.plugin, alternate_language=heartbeat.get('alternate_language')) project = heartbeat.get('project') or heartbeat.get('alternate_project') branch = None if heartbeat['entity_type'] == 'file': project, branch = get_project_info(configs, heartbeat) heartbeat['project'] = project heartbeat['branch'] = branch heartbeat['stats'] = stats heartbeat['hostname'] = hostname heartbeat['timeout'] = args.timeout heartbeat['key'] = args.key heartbeat['plugin'] = args.plugin heartbeat['offline'] = args.offline heartbeat['hidefilenames'] = args.hidefilenames heartbeat['proxy'] = args.proxy heartbeat['api_url'] = args.api_url return send_heartbeat(**heartbeat) else: log.debug('File does not exist; ignoring this heartbeat.') return SUCCESS def execute(argv=None): if argv: sys.argv = ['wakatime'] + argv args, configs = parseArguments() if configs is None: return CONFIG_FILE_PARSE_ERROR setup_logging(args, __version__) try: hostname = args.hostname or socket.gethostname() heartbeat = vars(args) retval = process_heartbeat(args, configs, hostname, heartbeat) if args.extra_heartbeats: try: for heartbeat in json.loads(sys.stdin.readline()): retval = process_heartbeat(args, configs, hostname, heartbeat) except json.JSONDecodeError: retval = MALFORMED_HEARTBEAT_ERROR if retval == SUCCESS: retval = sync_offline_heartbeats(args, hostname) return retval except: log.traceback(logging.ERROR) print(traceback.format_exc()) return UNKNOWN_ERROR