Repository: conan-io/conan
Pull request: Feature/6207 config single file
Changelog: Feature: Allow ``conan config install`` of a single file
Docs: https://github.com/conan-io/docs/pull/1908

Close https://github.com/conan-io/conan/issues/6207

* Add the possibility to specify a configuration for the command `conan config install`
* Copy only the configuration files in the directory, for the command `conan config install dirname`

Question :
* For what purpose, do we need the files copy, when the directory is pass by parameter ?

- [X] Refer to the issue that supports this Pull Request.
- [ ] If the issue has missing info, explain the purpose/use case/pain/need that covers this Pull Request.
- [x] I've read the [Contributing guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).
- [ ] I've followed the PEP8 style guides for Python code.
- [ ] I've opened another PR in the Conan docs repo to the ``develop`` branch, documenting this one.

Problem statement:
[feature] conan config install a single file
When using `conan config install`, allow a single configuration file to be installed. 

Normally, the command requires a git repository, a local folder, or a zip file that contains the file(s) to be installed. If I have a single file I want installed, I would have to put it into a folder or zip it into a file.

The current result:
```
conan config install my_settings\settings.yml
Traceback (most recent call last):
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\command.py", line 1859, in run
    method(args[0][1:])
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\command.py", line 554, in config
    target_folder=args.target_folder)
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\conan_api.py", line 78, in wrapper
    return f(*args, **kwargs)
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\conan_api.py", line 611, in config_install
    source_folder=source_folder, target_folder=target_folder)
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\conf\config_installer.py", line 235, in configuration_install
    _process_config(config, cache, output, requester)
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\conf\config_installer.py", line 190, in _process_config
    _process_zip_file(config, config.uri, cache, output, tmp_folder)
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\conf\config_installer.py", line 63, in _process_zip_file
    unzip(zippath, tmp_folder, output=output)
  File "C:\Users\Kyle.Kaja\AppData\Roaming\Python\Python37\site-packages\conans\client\tools\files.py", line 104, in unzip
    with zipfile.ZipFile(filename, "r") as z:
  File "C:\Program Files\Python37\lib\zipfile.py", line 1225, in __init__
    self._RealGetContents()
  File "C:\Program Files\Python37\lib\zipfile.py", line 1292, in _RealGetContents
    raise BadZipFile("File is not a zip file")
zipfile.BadZipFile: File is not a zip file

ERROR: File is not a zip file
```

This feature is useful in the case where you only want to install specific files from a single folder.
For example,
```
my_settings\
  settings.yml
  remotes.txt

conan config install my_settings  (Installs settings.yml and remotes.txt)
conan config install my_settings\settings.yml  (Installs settings.yml and ignores remotes.txt)
```

It is also useful when the current folder structure does not facilitate creating a folder specifically for configuration files (e.g. a git repository).
```
my_git_repo\
  settings.yml
  unrelated.txt
  ...

conan config install .\
Installing settings.yml
Copying file unrelated.txt to C:\Users\Kyle.Kaja\.conan\.  (I don't want this installed here!!)
...

OR

mkdir temp_settings
copy settings.yml temp_settings
conan config install temp_settings
Installing settings.yml
rmdir temp_settings /s  (Remove the directory so Git doesn't try to track that new file.)
(So much to do when all I want to do is install a single configuration file.)

PREFERRED

conan config install settings.yml  (Ahh! Simple, short, and intuitive!)
Installing settings.yml
```

Hints from issue discussion:
Hi @kkaja123 

I think this can make sense and shouldn't be very difficult to implement. The only thing is that our backlog is already totally full, so will label it accordingly and lets see if this feature gets contributed by the community. Thanks for the suggestion.
Hi,
I would like to contribute to this feature.
Hi @sagarafr 

Great! Assigned to you. Don't hesitate to ask if you have any question, good luck.

Commit message: chore(conf): Keep custom files install and specify compressed files

* Keep custom files installation
* Test if the uri is a compressed file and use decompression
  configuration function
* Test if the uri is a regular file and use file configuration function

Patch to review:
diff --git a/conans/client/conf/config_installer.py b/conans/client/conf/config_installer.py
index 72ea8f0c7e7..9e06cfcdf4f 100644
--- a/conans/client/conf/config_installer.py
+++ b/conans/client/conf/config_installer.py
@@ -12,7 +12,7 @@
 from conans.client import tools
 from conans.client.cache.remote_registry import load_registry_txt, migrate_registry_file
 from conans.client.tools import Git
-from conans.client.tools.files import unzip
+from conans.client.tools.files import unzip, is_compressed_file
 from conans.errors import ConanException
 from conans.util.files import mkdir, rmdir, walk, save, touch, remove
 from conans.client.cache.cache import ClientCache
@@ -86,6 +86,43 @@ def _filecopy(src, filename, dst):
     shutil.copyfile(src, dst)
 
 
+def _process_file(directory, filename, config, cache, output, folder):
+    if filename == "settings.yml":
+        output.info("Installing settings.yml")
+        _filecopy(directory, filename, cache.cache_folder)
+    elif filename == "conan.conf":
+        output.info("Processing conan.conf")
+        _handle_conan_conf(cache.config, os.path.join(directory, filename))
+    elif filename == "remotes.txt":
+        output.info("Defining remotes from remotes.txt")
+        _handle_remotes(cache, os.path.join(directory, filename))
+    elif filename in ("registry.txt", "registry.json"):
+        try:
+            os.remove(cache.remotes_path)
+        except OSError:
+            pass
+        finally:
+            _filecopy(directory, filename, cache.cache_folder)
+            migrate_registry_file(cache, output)
+    elif filename == "remotes.json":
+        # Fix for Conan 2.0
+        raise ConanException("remotes.json install is not supported yet. Use 'remotes.txt'")
+    else:
+        # This is ugly, should be removed in Conan 2.0
+        if filename in ("README.md", "LICENSE.txt"):
+            output.info("Skip %s" % filename)
+        else:
+            relpath = os.path.relpath(directory, folder)
+            if config.target_folder:
+                target_folder = os.path.join(cache.cache_folder, config.target_folder,
+                                             relpath)
+            else:
+                target_folder = os.path.join(cache.cache_folder, relpath)
+            mkdir(target_folder)
+            output.info("Copying file %s to %s" % (filename, target_folder))
+            _filecopy(directory, filename, target_folder)
+
+
 def _process_folder(config, folder, cache, output):
     if not os.path.isdir(folder):
         raise ConanException("No such directory: '%s'" % str(folder))
@@ -96,40 +133,7 @@ def _process_folder(config, folder, cache, output):
         if ".git" in root:
             continue
         for f in files:
-            if f == "settings.yml":
-                output.info("Installing settings.yml")
-                _filecopy(root, f, cache.cache_folder)
-            elif f == "conan.conf":
-                output.info("Processing conan.conf")
-                _handle_conan_conf(cache.config, os.path.join(root, f))
-            elif f == "remotes.txt":
-                output.info("Defining remotes from remotes.txt")
-                _handle_remotes(cache, os.path.join(root, f))
-            elif f in ("registry.txt", "registry.json"):
-                try:
-                    os.remove(cache.remotes_path)
-                except OSError:
-                    pass
-                finally:
-                    _filecopy(root, f, cache.cache_folder)
-                    migrate_registry_file(cache, output)
-            elif f == "remotes.json":
-                # Fix for Conan 2.0
-                raise ConanException("remotes.json install is not supported yet. Use 'remotes.txt'")
-            else:
-                # This is ugly, should be removed in Conan 2.0
-                if root == folder and f in ("README.md", "LICENSE.txt"):
-                    output.info("Skip %s" % f)
-                    continue
-                relpath = os.path.relpath(root, folder)
-                if config.target_folder:
-                    target_folder = os.path.join(cache.cache_folder, config.target_folder,
-                                                 relpath)
-                else:
-                    target_folder = os.path.join(cache.cache_folder, relpath)
-                mkdir(target_folder)
-                output.info("Copying file %s to %s" % (f, target_folder))
-                _filecopy(root, f, target_folder)
+            _process_file(root, f, config, cache, output, folder)
 
 
 def _process_download(config, cache, output, requester):
@@ -179,6 +183,8 @@ def from_item(uri, config_type, verify_ssl, args, source_folder, target_folder):
                 config.type = "git"
             elif os.path.isdir(uri):
                 config.type = "dir"
+            elif is_compressed_file(uri):
+                config.type = "compressed"
             elif os.path.isfile(uri):
                 config.type = "file"
             elif uri.startswith("http"):
@@ -201,9 +207,12 @@ def _process_config(config, cache, output, requester):
             _process_git_repo(config, cache, output)
         elif config.type == "dir":
             _process_folder(config, config.uri, cache, output)
-        elif config.type == "file":
+        elif config.type == "compressed":
             with tmp_config_install_folder(cache) as tmp_folder:
                 _process_zip_file(config, config.uri, cache, output, tmp_folder)
+        elif config.type == "file":
+            dirname, filename = os.path.dirname(config.uri), os.path.basename(config.uri)
+            _process_file(dirname, filename, config, cache, output, dirname)
         elif config.type == "url":
             _process_download(config, cache, output, requester=requester)
         else:
diff --git a/conans/client/tools/files.py b/conans/client/tools/files.py
index 01f0a327c7e..cbcd3b78b6e 100644
--- a/conans/client/tools/files.py
+++ b/conans/client/tools/files.py
@@ -53,6 +53,19 @@ def human_size(size_bytes):
     return "%s%s" % (formatted_size, suffix)
 
 
+def is_compressed_file(filename):
+    import zipfile
+    import tarfile
+    import binascii
+    if zipfile.is_zipfile(filename) or tarfile.is_tarfile(filename):
+        return True
+    # test gzip magic number
+    with open(filename, 'rb') as fd:
+        if binascii.hexlify(fd.read(2)) == b'1f8b':
+            return True
+    return False
+
+
 def unzip(filename, destination=".", keep_permissions=False, pattern=None, output=None):
     """
     Unzip a zipped file


Code review comments:
diff_hunk: @@ -201,9 +207,12 @@ def _process_config(config, cache, output, requester):
             _process_git_repo(config, cache, output)
         elif config.type == "dir":
             _process_folder(config, config.uri, cache, output)
-        elif config.type == "file":
+        elif config.type == "compressed":
             with tmp_config_install_folder(cache) as tmp_folder:
                 _process_zip_file(config, config.uri, cache, output, tmp_folder)
+        elif config.type == "file":
path: conans/client/conf/config_installer.py
text: @user1:
Hi @author

I am having a look, and there is a problem: we cannot change existing behavior unless it is declared a bug. At the moment the type for a zipped file, the type is "file", and that cannot be changed. It is not good enough to introduce a ``compressed`` type for differentiating this. The current ``file`` behavior has to be respected and maintained.

So I think it would be better to try to process files capturing the exception when the decompression fails.

@author:
Indeed, I understand my mistake when I have push. I just try to find the right place to make the check in order to have a cleaner code and have all tests pass.