1import json
2import os
3import shutil
4
5from datetime import datetime
6from dateutil.tz import gettz
7
8from contextlib import contextmanager
9from six.moves.urllib.parse import urlparse
10
11from conans import load
12from conans.client import tools
13from conans.client.cache.remote_registry import load_registry_txt, migrate_registry_file
14from conans.client.downloaders.file_downloader import FileDownloader
15from conans.client.tools import Git
16from conans.client.tools.files import unzip
17from conans.errors import ConanException
18from conans.util.files import mkdir, rmdir, walk, save, touch, remove
19from conans.client.cache.cache import ClientCache
20
21
22def _hide_password(resource):
23    """
24    Hide password from url/file path
25
26    :param resource: string with url or file path
27    :return: resource with hidden password if present
28    """
29    password = urlparse(resource).password
30    return resource.replace(password, "<hidden>") if password else resource
31
32
33def _handle_remotes(cache, remote_file):
34    # FIXME: Should we encourage to pass the remotes in json?
35    remotes, _ = load_registry_txt(load(remote_file))
36    cache.registry.define(remotes)
37
38
39@contextmanager
40def tmp_config_install_folder(cache):
41    tmp_folder = os.path.join(cache.cache_folder, "tmp_config_install")
42    # necessary for Mac OSX, where the temp folders in /var/ are symlinks to /private/var/
43    tmp_folder = os.path.realpath(tmp_folder)
44    rmdir(tmp_folder)
45    mkdir(tmp_folder)
46    try:
47        yield tmp_folder
48    finally:
49        rmdir(tmp_folder)
50
51
52def _process_git_repo(config, cache, output):
53    output.info("Trying to clone repo: %s" % config.uri)
54    with tmp_config_install_folder(cache) as tmp_folder:
55        with tools.chdir(tmp_folder):
56            try:
57                args = config.args or ""
58                git = Git(verify_ssl=config.verify_ssl, output=output)
59                git.clone(config.uri, args=args)
60                output.info("Repo cloned!")
61            except Exception as e:
62                raise ConanException("Can't clone repo: %s" % str(e))
63        _process_folder(config, tmp_folder, cache, output)
64
65
66def _process_zip_file(config, zippath, cache, output, tmp_folder, first_remove=False):
67    unzip(zippath, tmp_folder, output=output)
68    if first_remove:
69        os.unlink(zippath)
70    _process_folder(config, tmp_folder, cache, output)
71
72
73def _handle_conan_conf(current_conan_conf, new_conan_conf_path):
74    current_conan_conf.read(new_conan_conf_path)
75    with open(current_conan_conf.filename, "w") as f:
76        current_conan_conf.write(f)
77
78
79def _filecopy(src, filename, dst):
80    # https://github.com/conan-io/conan/issues/6556
81    # This is just a local convenience for "conan config install", using copyfile to avoid
82    # copying with permissions that later cause bugs
83    src = os.path.join(src, filename)
84    dst = os.path.join(dst, filename)
85    # Clear the destination file
86    if os.path.exists(dst):
87        if os.path.isdir(dst):  # dst was a directory and now src is a file
88            rmdir(dst)
89        else:
90            remove(dst)
91    shutil.copyfile(src, dst)
92
93
94def _process_file(directory, filename, config, cache, output, folder):
95    if filename == "settings.yml":
96        output.info("Installing settings.yml")
97        _filecopy(directory, filename, cache.cache_folder)
98    elif filename == "conan.conf":
99        output.info("Processing conan.conf")
100        _handle_conan_conf(cache.config, os.path.join(directory, filename))
101    elif filename == "remotes.txt":
102        output.info("Defining remotes from remotes.txt")
103        _handle_remotes(cache, os.path.join(directory, filename))
104    elif filename in ("registry.txt", "registry.json"):
105        try:
106            os.remove(cache.remotes_path)
107        except OSError:
108            pass
109        finally:
110            _filecopy(directory, filename, cache.cache_folder)
111            migrate_registry_file(cache, output)
112    elif filename == "remotes.json":
113        # Fix for Conan 2.0
114        raise ConanException("remotes.json install is not supported yet. Use 'remotes.txt'")
115    else:
116        # This is ugly, should be removed in Conan 2.0
117        if filename in ("README.md", "LICENSE.txt"):
118            output.info("Skip %s" % filename)
119        else:
120            relpath = os.path.relpath(directory, folder)
121            if config.target_folder:
122                target_folder = os.path.join(cache.cache_folder, config.target_folder,
123                                             relpath)
124            else:
125                target_folder = os.path.join(cache.cache_folder, relpath)
126
127            if os.path.exists(target_folder):
128                if os.path.isfile(target_folder):  # Existed as a file and now should be a folder
129                    remove(target_folder)
130
131            mkdir(target_folder)
132            output.info("Copying file %s to %s" % (filename, target_folder))
133            _filecopy(directory, filename, target_folder)
134
135
136def _process_folder(config, folder, cache, output):
137    if not os.path.isdir(folder):
138        raise ConanException("No such directory: '%s'" % str(folder))
139    if config.source_folder:
140        folder = os.path.join(folder, config.source_folder)
141    for root, dirs, files in walk(folder):
142        dirs[:] = [d for d in dirs if d != ".git"]
143        for f in files:
144            _process_file(root, f, config, cache, output, folder)
145
146
147def _process_download(config, cache, output, requester):
148    with tmp_config_install_folder(cache) as tmp_folder:
149        output.info("Trying to download  %s" % _hide_password(config.uri))
150        zippath = os.path.join(tmp_folder, os.path.basename(config.uri))
151        try:
152            downloader = FileDownloader(requester=requester, output=output, verify=config.verify_ssl,
153                                        config_retry=None, config_retry_wait=None)
154            downloader.download(url=config.uri, file_path=zippath)
155            _process_zip_file(config, zippath, cache, output, tmp_folder, first_remove=True)
156        except Exception as e:
157            raise ConanException("Error while installing config from %s\n%s" % (config.uri, str(e)))
158
159
160class _ConfigOrigin(object):
161    def __init__(self, data):
162        self.type = data.get("type")
163        self.uri = data.get("uri")
164        self.verify_ssl = data.get("verify_ssl")
165        self.args = data.get("args")
166        self.source_folder = data.get("source_folder")
167        self.target_folder = data.get("target_folder")
168
169    def __eq__(self, other):
170        return (self.type == other.type and self.uri == other.uri and
171                self.args == other.args and self.source_folder == other.source_folder
172                and self.target_folder == other.target_folder)
173
174    def __ne__(self, other):
175        return not self.__eq__(other)
176
177    def json(self):
178        return {"type": self.type,
179                "uri": self.uri,
180                "verify_ssl": self.verify_ssl,
181                "args": self.args,
182                "source_folder": self.source_folder,
183                "target_folder": self.target_folder}
184
185    @staticmethod
186    def from_item(uri, config_type, verify_ssl, args, source_folder, target_folder):
187        config = _ConfigOrigin({})
188        if config_type:
189            config.type = config_type
190        else:
191            if uri.endswith(".git"):
192                config.type = "git"
193            elif os.path.isdir(uri):
194                config.type = "dir"
195            elif os.path.isfile(uri):
196                config.type = "file"
197            elif uri.startswith("http"):
198                config.type = "url"
199            else:
200                raise ConanException("Unable to deduce type config install: %s" % uri)
201        config.source_folder = source_folder
202        config.target_folder = target_folder
203        config.args = args
204        config.verify_ssl = verify_ssl
205        if os.path.exists(uri):
206            uri = os.path.abspath(uri)
207        config.uri = uri
208        return config
209
210
211def _is_compressed_file(filename):
212    open(filename, "r")  # Check if the file exist and can be opened
213    import zipfile
214    if zipfile.is_zipfile(filename):
215        return True
216    if (filename.endswith(".tar.gz") or filename.endswith(".tgz") or
217            filename.endswith(".tbz2") or filename.endswith(".tar.bz2") or
218            filename.endswith(".tar") or filename.endswith(".gz") or
219            filename.endswith(".tar.xz") or filename.endswith(".txz")):
220        return True
221    return False
222
223
224def _process_config(config, cache, output, requester):
225    try:
226        if config.type == "git":
227            _process_git_repo(config, cache, output)
228        elif config.type == "dir":
229            _process_folder(config, config.uri, cache, output)
230        elif config.type == "file":
231            if _is_compressed_file(config.uri):
232                with tmp_config_install_folder(cache) as tmp_folder:
233                    _process_zip_file(config, config.uri, cache, output, tmp_folder)
234            else:
235                dirname, filename = os.path.split(config.uri)
236                _process_file(dirname, filename, config, cache, output, dirname)
237        elif config.type == "url":
238            _process_download(config, cache, output, requester=requester)
239        else:
240            raise ConanException("Unable to process config install: %s" % config.uri)
241    except Exception as e:
242        raise ConanException("Failed conan config install: %s" % str(e))
243
244
245def _save_configs(configs_file, configs):
246    save(configs_file, json.dumps([config.json() for config in configs],
247                                  indent=True))
248
249
250def _load_configs(configs_file):
251    try:
252        configs = json.loads(load(configs_file))
253    except Exception as e:
254        raise ConanException("Error loading configs-install file: %s\n%s"
255                             % (configs_file, str(e)))
256    return [_ConfigOrigin(config) for config in configs]
257
258
259def configuration_install(app, uri, verify_ssl, config_type=None,
260                          args=None, source_folder=None, target_folder=None):
261    cache, output, requester = app.cache, app.out, app.requester
262    configs = []
263    configs_file = cache.config_install_file
264    if os.path.isfile(configs_file):
265        configs = _load_configs(configs_file)
266    if uri is None:
267        if config_type or args or not verify_ssl:  # Not the defaults
268            if not configs:
269                raise ConanException("Called config install without arguments")
270            # Modify the last one
271            config = configs[-1]
272            config.config_type = config_type or config.type
273            config.args = args or config.args
274            config.verify_ssl = verify_ssl or config.verify_ssl
275            _process_config(config, cache, output, requester)
276            _save_configs(configs_file, configs)
277        else:
278            if not configs:
279                raise ConanException("Called config install without arguments")
280            # Execute the previously stored ones
281            for config in configs:
282                output.info("Config install:  %s" % _hide_password(config.uri))
283                _process_config(config, cache, output, requester)
284            touch(cache.config_install_file)
285    else:
286        # Execute and store the new one
287        config = _ConfigOrigin.from_item(uri, config_type, verify_ssl, args,
288                                         source_folder, target_folder)
289        _process_config(config, cache, output, requester)
290        if config not in configs:
291            configs.append(config)
292        else:
293            configs = [(c if c != config else config) for c in configs]
294        _save_configs(configs_file, configs)
295
296
297def _is_scheduled_intervals(file, interval):
298    """ Check if time interval is bigger than last file change
299
300    :param file: file path to stat last change
301    :param interval: required time interval
302    :return: True if last change - current time is bigger than interval. Otherwise, False.
303    """
304    timestamp = os.path.getmtime(file)
305    sched = datetime.fromtimestamp(timestamp, tz=gettz())
306    sched += interval
307    now = datetime.now(gettz())
308    return now > sched
309
310
311def is_config_install_scheduled(api):
312    """ Validate if the next config install is scheduled to occur now
313
314        When config_install_interval is not configured, config install should not run
315        When configs file is empty, scheduled config install should not run
316        When config_install_interval is configured, config install will respect the delta from:
317            last conan install execution (sched file) + config_install_interval value < now
318
319    :param api: Conan API instance
320    :return: True, if it should occur now. Otherwise, False.
321    """
322    cache = ClientCache(api.cache_folder, api.out)
323    interval = cache.config.config_install_interval
324    config_install_file = cache.config_install_file
325    if interval is not None:
326        if not os.path.exists(config_install_file):
327            raise ConanException("config_install_interval defined, but no config_install file")
328        scheduled = _is_scheduled_intervals(config_install_file, interval)
329        if scheduled and not _load_configs(config_install_file):
330            api.out.warn("Skipping scheduled config install, "
331                         "no config listed in config_install file")
332            os.utime(config_install_file, None)
333        else:
334            return scheduled
335