From 10396c5a385b747829e1103464f815eaef8316fc Mon Sep 17 00:00:00 2001 From: Ranjan Singh Date: Fri, 19 Dec 2025 00:29:53 +0530 Subject: [PATCH 1/5] Add --tag option to download command --- cloudsmith_cli/cli/commands/download.py | 11 ++++- cloudsmith_cli/core/download.py | 44 +++++++++++++++++ cloudsmith_cli/core/tests/test_download.py | 55 ++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/commands/download.py b/cloudsmith_cli/cli/commands/download.py index 65ced8a9..a29c62d3 100644 --- a/cloudsmith_cli/cli/commands/download.py +++ b/cloudsmith_cli/cli/commands/download.py @@ -42,6 +42,9 @@ @click.option( "--arch", "arch_filter", help="Architecture filter (e.g., 'amd64', 'arm64')." ) +@click.option( + "--tag", "tag_filter", help="Tag filter (e.g., 'latest', 'stable', 'beta')." +) @click.option( "--outfile", type=click.Path(), @@ -78,6 +81,7 @@ def download( # noqa: C901 format_filter, os_filter, arch_filter, + tag_filter, outfile, overwrite, all_files, @@ -88,7 +92,7 @@ def download( # noqa: C901 Download a package from a Cloudsmith repository. This command downloads a package binary from a Cloudsmith repository. You can - filter packages by version, format, operating system, and architecture. + filter packages by version, format, operating system, architecture, and tags. Examples: @@ -104,6 +108,10 @@ def download( # noqa: C901 # Download with filters and custom output name cloudsmith download myorg/myrepo mypackage --format deb --arch amd64 --outfile my-package.deb + \b + # Download a package with a specific tag + cloudsmith download myorg/myrepo mypackage --tag latest + \b # Download all associated files (POM, sources, javadoc, etc.) for a Maven/NuGet package cloudsmith download myorg/myrepo mypackage --all-files @@ -150,6 +158,7 @@ def download( # noqa: C901 format_filter=format_filter, os_filter=os_filter, arch_filter=arch_filter, + tag_filter=tag_filter, yes=yes, ) diff --git a/cloudsmith_cli/core/download.py b/cloudsmith_cli/core/download.py index f13cb202..bb9ed28c 100644 --- a/cloudsmith_cli/core/download.py +++ b/cloudsmith_cli/core/download.py @@ -53,6 +53,45 @@ def resolve_auth( return session, headers, auth_source +def _matches_tag_filter(pkg: Dict, tag_filter: str) -> bool: + """ + Check if a package matches the tag filter. + + Args: + pkg: Package dictionary + tag_filter: Tag to match against + + Returns: + True if package matches the tag filter + """ + # Check actual tags field (info, version, etc.) + pkg_tags = pkg.get("tags", {}) + for tag_category in pkg_tags.values(): + if isinstance(tag_category, list) and tag_filter in tag_category: + return True + + # Check other metadata fields that appear as tags in the UI + tag_lower = tag_filter.lower() + + # Check format, architectures, and deb component + if ( + pkg.get("format") == tag_filter + or any(arch.get("name") == tag_filter for arch in pkg.get("architectures", [])) + or pkg.get("identifiers", {}).get("deb_component") == tag_filter + ): + return True + + # Check distro-related fields + distro_name = pkg.get("distro", {}).get("name", "").lower() + distro_version = pkg.get("distro_version", {}).get("name", "").lower() + distro_combo = f"{pkg.get('distro', {}).get('name', '')}/{pkg.get('distro_version', {}).get('name', '')}".lower() + + if tag_lower in (distro_name, distro_version, distro_combo): + return True + + return False + + def resolve_package( owner: str, repo: str, @@ -62,6 +101,7 @@ def resolve_package( format_filter: Optional[str] = None, os_filter: Optional[str] = None, arch_filter: Optional[str] = None, + tag_filter: Optional[str] = None, yes: bool = False, ) -> Dict: """ @@ -75,6 +115,7 @@ def resolve_package( format_filter: Optional format filter os_filter: Optional OS filter arch_filter: Optional architecture filter + tag_filter: Optional tag filter yes: If True, automatically select best match when multiple found Returns: @@ -125,6 +166,9 @@ def resolve_package( # Apply architecture filter if arch_filter and pkg.get("architecture") != arch_filter: continue + # Apply tag filter + if tag_filter and not _matches_tag_filter(pkg, tag_filter): + continue filtered_packages.append(pkg) packages = filtered_packages diff --git a/cloudsmith_cli/core/tests/test_download.py b/cloudsmith_cli/core/tests/test_download.py index 27baceff..a840ee5e 100644 --- a/cloudsmith_cli/core/tests/test_download.py +++ b/cloudsmith_cli/core/tests/test_download.py @@ -151,6 +151,61 @@ def test_resolve_package_with_filters(self, mock_list_packages): page_size=100, ) + @patch("cloudsmith_cli.core.download.list_packages") + def test_resolve_package_with_tag_filter(self, mock_list_packages): + """Test package resolution with tag filter.""" + mock_packages = [ + { + "name": "test-package", + "version": "1.0.0", + "format": "deb", + "architectures": [{"name": "amd64"}], + "distro": {"name": "Ubuntu"}, + "distro_version": {"name": "noble"}, + "identifiers": {"deb_component": "main"}, + "tags": {"info": ["latest"], "version": ["stable"]}, + }, + { + "name": "test-package", + "version": "0.9.0", + "format": "rpm", + "architectures": [{"name": "arm64"}], + "distro": {"name": "CentOS"}, + "distro_version": {"name": "8"}, + "identifiers": {"deb_component": "contrib"}, + "tags": {"info": ["beta"], "version": ["unstable"]}, + }, + ] + mock_page_info = Mock() + mock_page_info.is_valid = True + mock_page_info.page = 1 + mock_page_info.page_total = 1 + mock_list_packages.return_value = (mock_packages, mock_page_info) + + # Test actual tag filtering + result = download.resolve_package( + "owner", "repo", "test-package", tag_filter="latest" + ) + self.assertEqual(result["version"], "1.0.0") + + # Test format filtering as tag + result = download.resolve_package( + "owner", "repo", "test-package", tag_filter="deb" + ) + self.assertEqual(result["format"], "deb") + + # Test architecture filtering as tag + result = download.resolve_package( + "owner", "repo", "test-package", tag_filter="arm64" + ) + self.assertEqual(result["architectures"][0]["name"], "arm64") + + # Test distro filtering as tag + result = download.resolve_package( + "owner", "repo", "test-package", tag_filter="ubuntu" + ) + self.assertEqual(result["distro"]["name"], "Ubuntu") + @patch("cloudsmith_cli.core.download.list_packages") def test_resolve_package_exact_name_match(self, mock_list_packages): """Test that only exact name matches are returned (not partial).""" From d3876740937d0503c085d873a703338f1a45ecd9 Mon Sep 17 00:00:00 2001 From: Ranjan Singh Date: Fri, 19 Dec 2025 01:05:46 +0530 Subject: [PATCH 2/5] Add download command in Readme --- README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/README.md b/README.md index 3d639ced..762bd0b1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The CLI currently supports the following commands (and sub-commands): - `delete`|`rm`: Delete a package from a repository. - `dependencies`|`deps`: List direct (non-transitive) dependencies for a package. - `docs`: Launch the help website in your browser. +- `download`: Download a package from a repository. - `entitlements`|`ents`: Manage the entitlements for a repository. - `create`|`new`: Create a new entitlement in a repository. - `delete`|`rm`: Delete an entitlement from a repository. @@ -249,6 +250,45 @@ cloudsmith push rpm --help ``` +## Downloading Packages + +You can download packages from repositories using the `cloudsmith download` command. The CLI supports various filtering options to help you find and download the exact package you need. + +For example, to download a specific package: + +``` +cloudsmith download your-account/your-repo package-name +``` + +You can filter by various attributes like version, format, architecture, operating system, and tags: + +``` +# Download a specific version +cloudsmith download your-account/your-repo package-name --version 1.2.3 + +# Filter by format and architecture +cloudsmith download your-account/your-repo package-name --format deb --arch amd64 + +# Filter by tag (e.g., latest, stable, beta) +cloudsmith download your-account/your-repo package-name --tag latest + +# Combine multiple filters +cloudsmith download your-account/your-repo package-name --tag stable --format deb --arch arm64 + +# Download all associated files (POM, sources, javadoc, etc.) +cloudsmith download your-account/your-repo package-name --all-files + +# Preview what would be downloaded without actually downloading +cloudsmith download your-account/your-repo package-name --dry-run +``` + +For more advanced usage and all available options: + +``` +cloudsmith download --help +``` + + ## Contributing Yes! Please do contribute, this is why we love open source. Please see [CONTRIBUTING](https://github.com/cloudsmith-io/cloudsmith-cli/blob/master/CONTRIBUTING.md) for contribution guidelines when making code changes or raising issues for bug reports, ideas, discussions and/or questions (i.e. help required). From f06ea01e7515c5ffdbd9718529138d7d65a959b3 Mon Sep 17 00:00:00 2001 From: Ranjan Singh Date: Fri, 19 Dec 2025 01:35:10 +0530 Subject: [PATCH 3/5] Add Changelog and make distro tags case case-insensitive --- CHANGELOG.md | 6 ++++++ cloudsmith_cli/core/download.py | 28 +++++++++++++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22ecdf75..bdc808b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Added `--tag` option to `download` command for filtering packages by tags +- Enhanced tag filtering to support all metadata fields shown as tags in UI (format, architecture, distribution, component, etc.) +- Added download command documentation to README with comprehensive usage examples + ## [1.10.1] - 2025-12-16 ### Fixed diff --git a/cloudsmith_cli/core/download.py b/cloudsmith_cli/core/download.py index bb9ed28c..b7e6c91b 100644 --- a/cloudsmith_cli/core/download.py +++ b/cloudsmith_cli/core/download.py @@ -73,20 +73,30 @@ def _matches_tag_filter(pkg: Dict, tag_filter: str) -> bool: # Check other metadata fields that appear as tags in the UI tag_lower = tag_filter.lower() - # Check format, architectures, and deb component + # Check format, architectures, and deb component (case-insensitive) if ( - pkg.get("format") == tag_filter - or any(arch.get("name") == tag_filter for arch in pkg.get("architectures", [])) - or pkg.get("identifiers", {}).get("deb_component") == tag_filter + str(pkg.get("format", "")).lower() == tag_lower + or any( + str(arch.get("name", "")).lower() == tag_lower + for arch in pkg.get("architectures", []) + ) + or str(pkg.get("identifiers", {}).get("deb_component", "")).lower() == tag_lower ): return True # Check distro-related fields - distro_name = pkg.get("distro", {}).get("name", "").lower() - distro_version = pkg.get("distro_version", {}).get("name", "").lower() - distro_combo = f"{pkg.get('distro', {}).get('name', '')}/{pkg.get('distro_version', {}).get('name', '')}".lower() - - if tag_lower in (distro_name, distro_version, distro_combo): + distro_name_raw = pkg.get("distro", {}).get("name") + distro_version_raw = pkg.get("distro_version", {}).get("name") + distro_name = (distro_name_raw or "").lower() + distro_version = (distro_version_raw or "").lower() + distro_combo = "" + if distro_name_raw and distro_version_raw: + # Only build combined tag when both parts are present + distro_combo = f"{distro_name_raw}/{distro_version_raw}".lower() + + if tag_lower in (distro_name, distro_version) or ( + distro_combo and tag_lower == distro_combo + ): return True return False From 831ac0254003716dd8d15239f0056c0958553180 Mon Sep 17 00:00:00 2001 From: Ranjan Singh Date: Fri, 19 Dec 2025 01:51:43 +0530 Subject: [PATCH 4/5] Fix tag filtering consistency and add comprehensive tests --- cloudsmith_cli/core/download.py | 25 +++--- cloudsmith_cli/core/tests/test_download.py | 97 +++++++++++++++++++--- 2 files changed, 96 insertions(+), 26 deletions(-) diff --git a/cloudsmith_cli/core/download.py b/cloudsmith_cli/core/download.py index b7e6c91b..d6326d0d 100644 --- a/cloudsmith_cli/core/download.py +++ b/cloudsmith_cli/core/download.py @@ -70,32 +70,27 @@ def _matches_tag_filter(pkg: Dict, tag_filter: str) -> bool: if isinstance(tag_category, list) and tag_filter in tag_category: return True - # Check other metadata fields that appear as tags in the UI - tag_lower = tag_filter.lower() - - # Check format, architectures, and deb component (case-insensitive) + # Check other metadata fields that appear as tags in the UI (case-sensitive) + # Check format, architectures, and deb component if ( - str(pkg.get("format", "")).lower() == tag_lower - or any( - str(arch.get("name", "")).lower() == tag_lower - for arch in pkg.get("architectures", []) - ) - or str(pkg.get("identifiers", {}).get("deb_component", "")).lower() == tag_lower + pkg.get("format") == tag_filter + or any(arch.get("name") == tag_filter for arch in pkg.get("architectures", [])) + or pkg.get("identifiers", {}).get("deb_component") == tag_filter ): return True # Check distro-related fields distro_name_raw = pkg.get("distro", {}).get("name") distro_version_raw = pkg.get("distro_version", {}).get("name") - distro_name = (distro_name_raw or "").lower() - distro_version = (distro_version_raw or "").lower() distro_combo = "" if distro_name_raw and distro_version_raw: # Only build combined tag when both parts are present - distro_combo = f"{distro_name_raw}/{distro_version_raw}".lower() + distro_combo = f"{distro_name_raw}/{distro_version_raw}" - if tag_lower in (distro_name, distro_version) or ( - distro_combo and tag_lower == distro_combo + if ( + tag_filter == distro_name_raw + or tag_filter == distro_version_raw + or (distro_combo and tag_filter == distro_combo) ): return True diff --git a/cloudsmith_cli/core/tests/test_download.py b/cloudsmith_cli/core/tests/test_download.py index a840ee5e..64c00901 100644 --- a/cloudsmith_cli/core/tests/test_download.py +++ b/cloudsmith_cli/core/tests/test_download.py @@ -182,29 +182,104 @@ def test_resolve_package_with_tag_filter(self, mock_list_packages): mock_page_info.page_total = 1 mock_list_packages.return_value = (mock_packages, mock_page_info) - # Test actual tag filtering + # Test actual tag filtering - should return v1.0.0 (has "latest" tag) result = download.resolve_package( "owner", "repo", "test-package", tag_filter="latest" ) - self.assertEqual(result["version"], "1.0.0") + self.assertEqual( + result["version"], "1.0.0" + ) # Only this package has "latest" tag - # Test format filtering as tag + # Test format filtering as tag - should return v1.0.0 (format "deb") result = download.resolve_package( "owner", "repo", "test-package", tag_filter="deb" ) - self.assertEqual(result["format"], "deb") + self.assertEqual( + result["version"], "1.0.0" + ) # Only this package has format "deb" - # Test architecture filtering as tag + # Test architecture filtering as tag - should return v0.9.0 (has "arm64") result = download.resolve_package( "owner", "repo", "test-package", tag_filter="arm64" ) - self.assertEqual(result["architectures"][0]["name"], "arm64") + self.assertEqual( + result["version"], "0.9.0" + ) # Only this package has arm64 architecture - # Test distro filtering as tag - result = download.resolve_package( - "owner", "repo", "test-package", tag_filter="ubuntu" - ) - self.assertEqual(result["distro"]["name"], "Ubuntu") + # Test distro filtering as tag - should fail due to case mismatch + with self.assertRaises(click.ClickException): + download.resolve_package( + "owner", + "repo", + "test-package", + tag_filter="ubuntu", # lowercase won't match "Ubuntu" + ) + + def test_matches_tag_filter_edge_cases(self): + """Test _matches_tag_filter function with edge cases.""" + + # Test package without tags field + pkg_no_tags = {"name": "test", "format": "deb"} + self.assertFalse(download._matches_tag_filter(pkg_no_tags, "latest")) + self.assertTrue( + download._matches_tag_filter(pkg_no_tags, "deb") + ) # format match + + # Test package with empty tags + pkg_empty_tags = {"tags": {}, "format": "rpm"} + self.assertFalse(download._matches_tag_filter(pkg_empty_tags, "latest")) + self.assertTrue(download._matches_tag_filter(pkg_empty_tags, "rpm")) + + # Test package with None/empty distro fields + pkg_no_distro = {"tags": {"info": ["test"]}} + self.assertFalse(download._matches_tag_filter(pkg_no_distro, "ubuntu")) + self.assertTrue(download._matches_tag_filter(pkg_no_distro, "test")) + + # Test case-sensitive matching for actual tags + pkg_case_tags = {"tags": {"info": ["Latest", "Beta"]}} + self.assertTrue(download._matches_tag_filter(pkg_case_tags, "Latest")) + self.assertFalse( + download._matches_tag_filter(pkg_case_tags, "latest") + ) # case mismatch + + # Test case-sensitive matching for metadata fields + pkg_case_meta = {"format": "Deb", "architectures": [{"name": "ARM64"}]} + self.assertTrue(download._matches_tag_filter(pkg_case_meta, "Deb")) + self.assertFalse( + download._matches_tag_filter(pkg_case_meta, "deb") + ) # case mismatch + self.assertTrue(download._matches_tag_filter(pkg_case_meta, "ARM64")) + self.assertFalse( + download._matches_tag_filter(pkg_case_meta, "arm64") + ) # case mismatch + + # Test component filtering + pkg_component = {"identifiers": {"deb_component": "main"}} + self.assertTrue(download._matches_tag_filter(pkg_component, "main")) + self.assertFalse(download._matches_tag_filter(pkg_component, "contrib")) + + # Test combined distro/version matching + pkg_distro_combo = { + "distro": {"name": "Ubuntu"}, + "distro_version": {"name": "noble"}, + } + self.assertTrue(download._matches_tag_filter(pkg_distro_combo, "Ubuntu")) + self.assertTrue(download._matches_tag_filter(pkg_distro_combo, "noble")) + self.assertTrue(download._matches_tag_filter(pkg_distro_combo, "Ubuntu/noble")) + self.assertFalse( + download._matches_tag_filter(pkg_distro_combo, "ubuntu/noble") + ) # case mismatch + + # Test distro combo with missing fields (should not create "/") + pkg_partial_distro = {"distro": {"name": "Ubuntu"}} # missing distro_version + self.assertTrue(download._matches_tag_filter(pkg_partial_distro, "Ubuntu")) + self.assertFalse( + download._matches_tag_filter(pkg_partial_distro, "Ubuntu/") + ) # should not match partial combo + + # Test empty architecture list + pkg_empty_arch = {"architectures": []} + self.assertFalse(download._matches_tag_filter(pkg_empty_arch, "amd64")) @patch("cloudsmith_cli.core.download.list_packages") def test_resolve_package_exact_name_match(self, mock_list_packages): From 628c873ed4bd4dff72ec2ccbe69ccdf081ac6958 Mon Sep 17 00:00:00 2001 From: Ranjan Singh Date: Fri, 19 Dec 2025 01:57:58 +0530 Subject: [PATCH 5/5] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 762bd0b1..63467d1f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The CLI currently supports the following commands (and sub-commands): - `delete`|`rm`: Delete a package from a repository. - `dependencies`|`deps`: List direct (non-transitive) dependencies for a package. - `docs`: Launch the help website in your browser. -- `download`: Download a package from a repository. +- `download`: Download a package from a repository. - `entitlements`|`ents`: Manage the entitlements for a repository. - `create`|`new`: Create a new entitlement in a repository. - `delete`|`rm`: Delete an entitlement from a repository.