Repository: conan-io/conan

Issue:
[feature] Built-in automatic "conan config install"
The most common request for a "global init hook", as proposed in:

https://github.com/conan-io/conan/pull/6434
https://github.com/conan-io/conan/issues/6423

Is to achieve the automatic call to ``conan config install`` periodically by developers when they use Conan. 
Implementing it in hooks has several challenges, so we want to implement this as a built-in feature:

- It uses conan.conf to define a minimal interval of time (hours, minutes, days?) between consecutive calls to ``conan config install``, to avoid doing one call per command, which would be very time consuming and annoying
- Conan ``command.py`` can read the conf, compare the current time, with the latest time stored in the cache (as its own file, or as the "touch" time of version.txt, if that is robust), and if the time elapsed is greater than the configured one, it will be calling "conan config install" via the ``conan_api.py``
- ``conan config install`` doesn't require arguments, it knows how to repeat latest config-installs.
- Make sure to reset the relevant bits, probably a completely new ConanApp.


Discussion:

Resolving patch:
diff --git a/conans/client/command.py b/conans/client/command.py
--- a/conans/client/command.py
+++ b/conans/client/command.py
@@ -14,6 +14,7 @@
 from conans.client.cmd.uploader import UPLOAD_POLICY_FORCE, \
     UPLOAD_POLICY_NO_OVERWRITE, UPLOAD_POLICY_NO_OVERWRITE_RECIPE, UPLOAD_POLICY_SKIP
 from conans.client.conan_api import Conan, default_manifest_folder, _make_abs_path, ProfileData
+from conans.client.conf.config_installer import is_config_install_scheduled
 from conans.client.conan_command_output import CommandOutputer
 from conans.client.output import Color
 from conans.client.printer import Printer
@@ -2006,6 +2007,10 @@ def run(self, *args):
                 self._print_similar(command)
                 raise ConanException("Unknown command %s" % str(exc))
 
+            if is_config_install_scheduled(self._conan) and \
+               (command != "config" or (command == "config" and args[0] != "install")):
+                self._conan.config_install(None, None)
+
             method(args[0][1:])
         except KeyboardInterrupt as exc:
             logger.error(exc)
diff --git a/conans/client/conf/__init__.py b/conans/client/conf/__init__.py
--- a/conans/client/conf/__init__.py
+++ b/conans/client/conf/__init__.py
@@ -1,6 +1,8 @@
 import logging
 import os
+import re
 import textwrap
+from datetime import timedelta
 
 from jinja2 import Template
 from six.moves.configparser import ConfigParser, NoSectionError
@@ -173,6 +175,8 @@ def get_default_settings_yml(force_v1=False):
     revisions_enabled = 1
     {% endif %}
 
+    # config_install_interval = 1h
+
     [storage]
     # This is the default path, but you can write your own. It must be an absolute path or a
     # path beginning with "~" (if the environment var CONAN_USER_HOME is specified, this directory, even
@@ -684,3 +688,23 @@ def get_log_level_by_name(level_name):
             "notset": logging.NOTSET
         }
         return levels.get(str(level_name).lower())
+
+    @property
+    def config_install_interval(self):
+        try:
+            interval = self.get_item("general.config_install_interval")
+        except ConanException:
+            return None
+
+        match = re.search(r"(\d+)([mhd])", interval)
+        try:
+            value, unit = match.group(1), match.group(2)
+            if unit == 'm':
+                return timedelta(minutes=float(value))
+            elif unit == 'h':
+                return timedelta(hours=float(value))
+            else:
+                return timedelta(days=float(value))
+        except Exception as e:
+            raise ConanException("Incorrect definition of general.config_install_interval: %s"
+                                 % interval)
diff --git a/conans/client/conf/config_installer.py b/conans/client/conf/config_installer.py
--- a/conans/client/conf/config_installer.py
+++ b/conans/client/conf/config_installer.py
@@ -1,6 +1,8 @@
 import json
 import os
 import shutil
+from datetime import datetime
+from dateutil.tz import gettz
 
 from contextlib import contextmanager
 from six.moves.urllib.parse import urlparse
@@ -12,7 +14,8 @@
 from conans.client.tools import Git
 from conans.client.tools.files import unzip
 from conans.errors import ConanException
-from conans.util.files import mkdir, rmdir, walk, save
+from conans.util.files import mkdir, rmdir, walk, save, touch
+from conans.client.cache.cache import ClientCache
 
 
 def _hide_password(resource):
@@ -237,6 +240,7 @@ def configuration_install(app, uri, verify_ssl, config_type=None,
             for config in configs:
                 output.info("Config install:  %s" % _hide_password(config.uri))
                 _process_config(config, cache, output, requester)
+            touch(cache.config_install_file)
     else:
         # Execute and store the new one
         config = _ConfigOrigin.from_item(uri, config_type, verify_ssl, args,
@@ -247,3 +251,27 @@ def configuration_install(app, uri, verify_ssl, config_type=None,
         else:
             configs = [(c if c != config else config) for c in configs]
         _save_configs(configs_file, configs)
+
+
+def is_config_install_scheduled(api):
+    """ Validate if the next config install is scheduled to occur now
+
+        When config_install_interval is not configured, config install should not run
+        When config_install_interval is configured, config install will respect the delta from:
+            last conan install execution (sched file) + config_install_interval value < now
+
+    :param api: Conan API instance
+    :return: True, if it should occur now. Otherwise, False.
+    """
+    cache = ClientCache(api.cache_folder, api.out)
+    interval = cache.config.config_install_interval
+    config_install_file = cache.config_install_file
+    if interval is not None:
+        if os.path.exists(config_install_file):
+            timestamp = os.path.getmtime(config_install_file)
+            sched = datetime.fromtimestamp(timestamp, tz=gettz())
+            sched += interval
+            now = datetime.now(gettz())
+            return now > sched
+        else:
+            raise ConanException("config_install_interval defined, but no config_install file")


Test patch:
diff --git a/conans/test/functional/command/config_install_test.py b/conans/test/functional/command/config_install_test.py
--- a/conans/test/functional/command/config_install_test.py
+++ b/conans/test/functional/command/config_install_test.py
@@ -2,6 +2,7 @@
 import os
 import shutil
 import textwrap
+import time
 import unittest
 import zipfile
 
@@ -64,6 +65,11 @@
     return a + b
 """
 
+conanconf_interval = """
+[general]
+config_install_interval = 5m
+"""
+
 
 def zipdir(path, zipfilename):
     with zipfile.ZipFile(zipfilename, 'w', zipfile.ZIP_DEFLATED) as z:
@@ -521,3 +527,67 @@ def get_zip():
         self.client.run("config install http://localhost:%s/myconfig.zip" % http_server.port)
         self.assertIn("Unzipping", self.client.out)
         http_server.stop()
+
+
+class ConfigInstallSchedTest(unittest.TestCase):
+
+    def setUp(self):
+        self.folder = temp_folder(path_with_spaces=False)
+        save_files(self.folder, {"conan.conf": conanconf_interval})
+        self.client = TestClient()
+
+    def test_config_install_sched_file(self):
+        """ Config install can be executed without restriction
+        """
+        self.client.run('config install "%s"' % self.folder)
+        self.assertIn("Processing conan.conf", self.client.out)
+        content = load(self.client.cache.conan_conf_path)
+        self.assertEqual(1, content.count("config_install_interval"))
+        self.assertIn("config_install_interval = 5m", content.splitlines())
+        self.assertTrue(os.path.exists(self.client.cache.config_install_file))
+        self.assertLess(os.path.getmtime(self.client.cache.config_install_file), time.time() + 1)
+
+    def test_execute_more_than_once(self):
+        """ Once executed by the scheduler, conan config install must executed again
+            when invoked manually
+        """
+        self.client.run('config install "%s"' % self.folder)
+        self.assertIn("Processing conan.conf", self.client.out)
+
+        self.client.run('config install "%s"' % self.folder)
+        self.assertIn("Processing conan.conf", self.client.out)
+        self.assertLess(os.path.getmtime(self.client.cache.config_install_file), time.time() + 1)
+
+    def test_sched_timeout(self):
+        """ Conan config install must be executed when the scheduled time reaches
+        """
+        self.client.run('config install "%s"' % self.folder)
+        self.client.run('config set general.config_install_interval=1m')
+        self.assertNotIn("Processing conan.conf", self.client.out)
+        past_time = int(time.time() - 120)  # 120 seconds in the past
+        os.utime(self.client.cache.config_install_file, (past_time, past_time))
+
+        self.client.run('search')  # any command will fire it
+        self.assertIn("Processing conan.conf", self.client.out)
+        self.client.run('search')  # not again, it was fired already
+        self.assertNotIn("Processing conan.conf", self.client.out)
+        self.client.run('config get general.config_install_interval')
+        self.assertNotIn("Processing conan.conf", self.client.out)
+        self.assertIn("5m", self.client.out)  # The previous 5 mins has been restored!
+
+    def test_invalid_scheduler(self):
+        """ An exception must be raised when conan_config.json is not listed
+        """
+        self.client.run('config install "%s"' % self.folder)
+        os.remove(self.client.cache.config_install_file)
+        self.client.run('config get general.config_install_interval', assert_error=True)
+        self.assertIn("config_install_interval defined, but no config_install file", self.client.out)
+
+    def test_invalid_time_interval(self):
+        """ config_install_interval only accepts minutes, hours or days
+        """
+        self.client.run('config set general.config_install_interval=1s')
+        # Any conan invocation will fire the configuration error
+        self.client.run('install .', assert_error=True)
+        self.assertIn("ERROR: Incorrect definition of general.config_install_interval: 1s",
+                      self.client.out)
diff --git a/conans/test/unittests/util/which_test.py b/conans/test/unittests/util/which_test.py
--- a/conans/test/unittests/util/which_test.py
+++ b/conans/test/unittests/util/which_test.py
@@ -15,7 +15,8 @@ class WhichTest(unittest.TestCase):
     @staticmethod
     def _touch(filename):
         with open(filename, 'a'):
-            os.utime(filename, None)
+            pass
+        os.utime(filename, None)
 
     @staticmethod
     def _add_executable_bit(filename):