1"""Collect and send usage analytics"""
2
3from __future__ import unicode_literals
4
5from dvc.utils.compat import str
6
7import os
8import json
9import errno
10
11import dvc.logger as logger
12from dvc import VERSION
13
14
15class Analytics(object):
16    """Class for collecting and sending usage analytics.
17
18    Args:
19        info (dict): optional existing analytics report.
20    """
21
22    URL = "https://analytics.dvc.org"
23    TIMEOUT_POST = 5
24
25    USER_ID_FILE = "user_id"
26
27    PARAM_DVC_VERSION = "dvc_version"
28    PARAM_USER_ID = "user_id"
29    PARAM_SYSTEM_INFO = "system_info"
30
31    PARAM_OS = "os"
32
33    PARAM_WINDOWS_VERSION_MAJOR = "windows_version_major"
34    PARAM_WINDOWS_VERSION_MINOR = "windows_version_minor"
35    PARAM_WINDOWS_VERSION_BUILD = "windows_version_build"
36    PARAM_WINDOWS_VERSION_SERVICE_PACK = "windows_version_service_pack"
37
38    PARAM_MAC_VERSION = "mac_version"
39
40    PARAM_LINUX_DISTRO = "linux_distro"
41    PARAM_LINUX_DISTRO_VERSION = "linux_distro_version"
42    PARAM_LINUX_DISTRO_LIKE = "linux_distro_like"
43
44    PARAM_SCM_CLASS = "scm_class"
45    PARAM_IS_BINARY = "is_binary"
46    PARAM_CMD_CLASS = "cmd_class"
47    PARAM_CMD_RETURN_CODE = "cmd_return_code"
48
49    def __init__(self, info=None):
50        from dvc.config import Config
51        from dvc.lock import Lock
52
53        if info is None:
54            info = {}
55
56        self.info = info
57
58        cdir = Config.get_global_config_dir()
59        try:
60            os.makedirs(cdir)
61        except OSError as exc:
62            if exc.errno != errno.EEXIST:
63                raise
64
65        self.user_id_file = os.path.join(cdir, self.USER_ID_FILE)
66        self.user_id_file_lock = Lock(cdir, self.USER_ID_FILE + ".lock")
67
68    @staticmethod
69    def load(path):
70        """Loads analytics report from json file specified by path.
71
72        Args:
73            path (str): path to json file with analytics report.
74        """
75        with open(path, "r") as fobj:
76            analytics = Analytics(info=json.load(fobj))
77        os.unlink(path)
78        return analytics
79
80    def _write_user_id(self):
81        import uuid
82
83        with open(self.user_id_file, "w+") as fobj:
84            user_id = str(uuid.uuid4())
85            info = {self.PARAM_USER_ID: user_id}
86            json.dump(info, fobj)
87            return user_id
88
89    def _read_user_id(self):
90        if not os.path.exists(self.user_id_file):
91            return None
92
93        with open(self.user_id_file, "r") as fobj:
94            try:
95                info = json.load(fobj)
96                return info[self.PARAM_USER_ID]
97            except json.JSONDecodeError as exc:
98                logger.debug("Failed to load user_id: {}".format(exc))
99                return None
100
101    def _get_user_id(self):
102        from dvc.lock import LockError
103
104        try:
105            with self.user_id_file_lock:
106                user_id = self._read_user_id()
107                if user_id is None:
108                    user_id = self._write_user_id()
109                return user_id
110        except LockError:
111            msg = "Failed to acquire '{}'"
112            logger.debug(msg.format(self.user_id_file_lock.lock_file))
113
114    def _collect_windows(self):
115        import sys
116
117        version = sys.getwindowsversion()  # pylint: disable=no-member
118        info = {}
119        info[self.PARAM_OS] = "windows"
120        info[self.PARAM_WINDOWS_VERSION_MAJOR] = version.major
121        info[self.PARAM_WINDOWS_VERSION_MINOR] = version.minor
122        info[self.PARAM_WINDOWS_VERSION_BUILD] = version.build
123        info[self.PARAM_WINDOWS_VERSION_SERVICE_PACK] = version.service_pack
124        return info
125
126    def _collect_darwin(self):
127        import platform
128
129        info = {}
130        info[self.PARAM_OS] = "mac"
131        info[self.PARAM_MAC_VERSION] = platform.mac_ver()[0]
132        return info
133
134    def _collect_linux(self):
135        import distro
136
137        info = {}
138        info[self.PARAM_OS] = "linux"
139        info[self.PARAM_LINUX_DISTRO] = distro.id()
140        info[self.PARAM_LINUX_DISTRO_VERSION] = distro.version()
141        info[self.PARAM_LINUX_DISTRO_LIKE] = distro.like()
142        return info
143
144    def _collect_system_info(self):
145        import platform
146
147        system = platform.system()
148
149        if system == "Windows":
150            return self._collect_windows()
151
152        if system == "Darwin":
153            return self._collect_darwin()
154
155        if system == "Linux":
156            return self._collect_linux()
157
158        raise NotImplementedError
159
160    def collect(self):
161        """Collect analytics report."""
162        from dvc.scm import SCM
163        from dvc.utils import is_binary
164        from dvc.repo import Repo
165        from dvc.exceptions import NotDvcRepoError
166
167        self.info[self.PARAM_DVC_VERSION] = VERSION
168        self.info[self.PARAM_IS_BINARY] = is_binary()
169        self.info[self.PARAM_USER_ID] = self._get_user_id()
170
171        self.info[self.PARAM_SYSTEM_INFO] = self._collect_system_info()
172
173        try:
174            scm = SCM(root_dir=Repo.find_root())
175            self.info[self.PARAM_SCM_CLASS] = type(scm).__name__
176        except NotDvcRepoError:
177            pass
178
179    def collect_cmd(self, args, ret):
180        """Collect analytics info from a CLI command."""
181        from dvc.command.daemon import CmdDaemonAnalytics
182
183        assert isinstance(ret, int) or ret is None
184
185        if ret is not None:
186            self.info[self.PARAM_CMD_RETURN_CODE] = ret
187
188        if args is not None and hasattr(args, "func"):
189            assert args.func != CmdDaemonAnalytics
190            self.info[self.PARAM_CMD_CLASS] = args.func.__name__
191
192    def dump(self):
193        """Save analytics report to a temporary file.
194
195        Returns:
196            str: path to the temporary file that contains the analytics report.
197        """
198        import tempfile
199
200        with tempfile.NamedTemporaryFile(delete=False, mode="w") as fobj:
201            json.dump(self.info, fobj)
202            return fobj.name
203
204    @staticmethod
205    def _is_enabled(cmd=None):
206        from dvc.config import Config
207        from dvc.repo import Repo
208        from dvc.exceptions import NotDvcRepoError
209        from dvc.command.daemon import CmdDaemonBase
210
211        if os.getenv("DVC_TEST"):
212            return False
213
214        if isinstance(cmd, CmdDaemonBase):
215            return False
216
217        if cmd is None or not hasattr(cmd, "config"):
218            try:
219                dvc_dir = Repo.find_dvc_dir()
220                config = Config(dvc_dir)
221                assert config is not None
222            except NotDvcRepoError:
223                config = Config(validate=False)
224                assert config is not None
225        else:
226            config = cmd.config
227            assert config is not None
228
229        core = config.config.get(Config.SECTION_CORE, {})
230        enabled = core.get(Config.SECTION_CORE_ANALYTICS, True)
231        logger.debug(
232            "Analytics is {}abled.".format("en" if enabled else "dis")
233        )
234        return enabled
235
236    @staticmethod
237    def send_cmd(cmd, args, ret):
238        """Collect and send analytics for CLI command.
239
240        Args:
241            args (list): parsed args for the CLI command.
242            ret (int): return value of the CLI command.
243        """
244        from dvc.daemon import daemon
245
246        if not Analytics._is_enabled(cmd):
247            return
248
249        analytics = Analytics()
250        analytics.collect_cmd(args, ret)
251        daemon(["analytics", analytics.dump()])
252
253    def send(self):
254        """Collect and send analytics."""
255        import requests
256
257        if not self._is_enabled():
258            return
259
260        self.collect()
261
262        logger.debug("Sending analytics: {}".format(self.info))
263
264        try:
265            requests.post(self.URL, json=self.info, timeout=self.TIMEOUT_POST)
266        except requests.exceptions.RequestException as exc:
267            logger.debug("Failed to send analytics: {}".format(str(exc)))
268