diff --git a/conan/internal/api/uploader.py b/conan/internal/api/uploader.py index 4f46738a2dd..23b5ea40a8b 100644 --- a/conan/internal/api/uploader.py +++ b/conan/internal/api/uploader.py @@ -11,7 +11,7 @@ from conan.internal.errors import NotFoundException from conan.errors import ConanException from conan.internal.paths import CONAN_MANIFEST, CONANFILE, CONANINFO, COMPRESSIONS, \ - EXPORT_SOURCES_FILE_NAME, EXPORT_FILE_NAME, PACKAGE_FILE_NAME + EXPORT_SOURCES_FILE_NAME, EXPORT_FILE_NAME, PACKAGE_FILE_NAME, CONAN_METADATA_SUBFOLDER from conan.internal.util.files import (clean_dirty, is_dirty, gather_files, set_dirty_context_manager, mkdir, human_size) @@ -133,6 +133,9 @@ def prepare(self, pkg_list, enabled_remotes, metadata, force=False): bundle.pop("upload-urls", None) if bundle.get("upload") or force: self._prepare_recipe(recipe_layout, ref, bundle, conanfile, enabled_remotes) + conan_files = _conan_metadata_files(recipe_layout.metadata()) + if conan_files: + bundle.setdefault("files", {}).update(conan_files) # Package metadata files too if metadata != [""] and (metadata or bundle.get("upload")): @@ -377,6 +380,21 @@ def _total_size(cache_files): return human_size(total_size) +def _conan_metadata_files(metadata_folder): + """Collect files from metadata/.conan subfolder for automatic upload with the recipe.""" + conan_subfolder = os.path.join(metadata_folder, CONAN_METADATA_SUBFOLDER) + result = {} + if not os.path.isdir(conan_subfolder): + return result + for root, _, files in os.walk(conan_subfolder): + for f in files: + abs_path = os.path.join(root, f) + relpath = os.path.relpath(abs_path, metadata_folder) + path = os.path.join("metadata", relpath).replace("\\", "/") + result[path] = abs_path + return result + + def _metadata_files(folder, metadata): result = {} for root, _, files in os.walk(folder): diff --git a/conan/internal/paths.py b/conan/internal/paths.py index adc7d0dfa6f..bca1e68704c 100644 --- a/conan/internal/paths.py +++ b/conan/internal/paths.py @@ -91,3 +91,4 @@ def _user_home_from_conanrc_file(): EXPORT_SOURCES_FILE_NAME = "conan_sources.t" COMPRESSIONS = "gz", "xz", "zst" DATA_YML = "conandata.yml" +CONAN_METADATA_SUBFOLDER = ".conan" diff --git a/conan/internal/rest/rest_client_v2.py b/conan/internal/rest/rest_client_v2.py index 9033e6d9189..5284fd8642a 100644 --- a/conan/internal/rest/rest_client_v2.py +++ b/conan/internal/rest/rest_client_v2.py @@ -10,7 +10,7 @@ from conan.api.output import ConanOutput from conan.internal.paths import EXPORT_SOURCES_FILE_NAME, CONANINFO, CONAN_MANIFEST, \ - EXPORT_FILE_NAME, PACKAGE_FILE_NAME + EXPORT_FILE_NAME, PACKAGE_FILE_NAME, CONAN_METADATA_SUBFOLDER from conan.internal.rest.caching_file_downloader import ConanInternalCacheDownloader from conan.internal.rest import response_to_str from conan.internal.rest.client_routes import ClientV2Router @@ -212,7 +212,8 @@ def get_recipe(self, ref, dest_folder, metadata, only_metadata): result = {} if not only_metadata: - accepted_files = ["conanfile.py", CONAN_MANIFEST, "metadata/sign"] + accepted_files = ["conanfile.py", CONAN_MANIFEST, "metadata/sign", + f"metadata/{CONAN_METADATA_SUBFOLDER}"] files = [f for f in server_files if any(f.startswith(m) for m in accepted_files)] export_file = self._find_compressed_file(ref, server_files, EXPORT_FILE_NAME) if export_file is not None: diff --git a/test/integration/metadata/test_metadata_conan_subfolder.py b/test/integration/metadata/test_metadata_conan_subfolder.py new file mode 100644 index 00000000000..82fb3c07838 --- /dev/null +++ b/test/integration/metadata/test_metadata_conan_subfolder.py @@ -0,0 +1,47 @@ +import os +import pytest + +from conan.test.assets.genconanfile import GenConanfile +from conan.test.utils.tools import TestClient +from conan.internal.util.files import save, load + + +class TestConanMetadataSubfolder: + + @pytest.fixture() + def uploaded_pkg(self): + """Create and upload pkg/0.1 with metadata/.conan/info.txt, no --metadata flag.""" + c = TestClient(default_server_user=True, light=True) + c.save({"conanfile.py": GenConanfile("pkg", "0.1")}) + c.run("create .") + c.run("cache path pkg/0.1 --folder=metadata") + metadata_path = str(c.stdout).strip() + save(os.path.join(metadata_path, ".conan", "info.txt"), "conan metadata content") + c.run("upload * -c -r=default") + return c + + def test_create_upload_install(self, uploaded_pkg): + """metadata/.conan is always uploaded with recipe and always downloaded on install.""" + c2 = TestClient(servers=uploaded_pkg.servers, light=True) + c2.run("install --requires=pkg/0.1") + c2.run("cache path pkg/0.1 --folder=metadata") + metadata_path = str(c2.stdout).strip() + assert load(os.path.join(metadata_path, ".conan", "info.txt")) == "conan metadata content" + + def test_download_irrespective_of_metadata_filter(self, uploaded_pkg): + """conan download always gets metadata/.conan regardless of --metadata filter.""" + c2 = TestClient(servers=uploaded_pkg.servers, light=True) + + # Without --metadata flag: .conan is still fetched + c2.run("download pkg/0.1 -r=default") + c2.run("cache path pkg/0.1 --folder=metadata") + metadata_path = str(c2.stdout).strip() + assert load(os.path.join(metadata_path, ".conan", "info.txt")) == "conan metadata content" + + c2.run("remove * -c") + + # With --metadata filtering other patterns: .conan subfolder still fetched + c2.run("download pkg/0.1 -r=default --metadata=other/*") + c2.run("cache path pkg/0.1 --folder=metadata") + metadata_path = str(c2.stdout).strip() + assert load(os.path.join(metadata_path, ".conan", "info.txt")) == "conan metadata content"