diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0e4b90..65a67793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### 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 - Added `CLOUDSMITH_NO_KEYRING` environment variable to disable keyring usage globally. Set `CLOUDSMITH_NO_KEYRING=1` to skip system keyring operations. - Added `--request-api-key` flag to `cloudsmith auth` command for fully automated, non-interactive API token retrieval. Auto-creates a token if none exists, or auto-rotates (with warning) if one already exists. Compatible with `--save-config` and `CLOUDSMITH_NO_KEYRING`. diff --git a/README.md b/README.md index 94b43c1b..b67e8d48 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 package tag (e.g., latest, stable, beta) +cloudsmith download your-account/your-repo package-name --tag latest + +# Combine tag with metadata 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). diff --git a/cloudsmith_cli/cli/commands/download.py b/cloudsmith_cli/cli/commands/download.py index 83f5c9f5..4ee25131 100644 --- a/cloudsmith_cli/cli/commands/download.py +++ b/cloudsmith_cli/cli/commands/download.py @@ -42,6 +42,11 @@ @click.option( "--arch", "arch_filter", help="Architecture filter (e.g., 'amd64', 'arm64')." ) +@click.option( + "--tag", + "tag_filter", + help="Filter by package tag (e.g., 'latest', 'stable'). Use --format, --arch, --os for metadata filters.", +) @click.option( "--outfile", type=click.Path(), @@ -78,6 +83,7 @@ def download( # noqa: C901 format_filter, os_filter, arch_filter, + tag_filter, outfile, overwrite, all_files, @@ -88,7 +94,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 +110,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 +160,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/cli/tests/commands/test_download.py b/cloudsmith_cli/cli/tests/commands/test_download.py index 5a00827a..12552030 100644 --- a/cloudsmith_cli/cli/tests/commands/test_download.py +++ b/cloudsmith_cli/cli/tests/commands/test_download.py @@ -9,7 +9,59 @@ class TestDownloadCommand(unittest.TestCase): - """Test the download CLI command.""" + @patch("cloudsmith_cli.core.download.list_packages") + @patch("cloudsmith_cli.cli.commands.download.resolve_auth") + def test_download_with_tag_filter_integration( + self, mock_resolve_auth, mock_list_packages + ): + """Integration test: download command with --tag filter (end-to-end).""" + mock_session = Mock() + mock_resolve_auth.return_value = (mock_session, {}, "none") + + # Simulate two packages, only one matches the tag + mock_packages = [ + { + "name": "test-package", + "version": "1.0.0", + "format": "deb", + "tags": {"info": ["latest", "beta"]}, + "filename": "test-package_1.0.0.deb", + "cdn_url": "https://example.com/test-package_1.0.0.deb", + "size": 1024, + }, + { + "name": "test-package", + "version": "0.9.0", + "format": "deb", + "tags": {"info": ["beta"]}, + "filename": "test-package_0.9.0.deb", + "cdn_url": "https://example.com/test-package_0.9.0.deb", + "size": 512, + }, + ] + 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) + + runner = CliRunner() + result = runner.invoke( + download, + [ + "--config-file", + "/dev/null", + "testorg/testrepo", + "test-package", + "--tag", + "latest", + "--dry-run", + ], + ) + + self.assertEqual(result.exit_code, 0) + self.assertIn("test-package v1.0.0", result.output) + self.assertNotIn("test-package v0.9.0", result.output) def setUp(self): self.runner = CliRunner() diff --git a/cloudsmith_cli/core/download.py b/cloudsmith_cli/core/download.py index 4b0c1941..14af4b04 100644 --- a/cloudsmith_cli/core/download.py +++ b/cloudsmith_cli/core/download.py @@ -53,6 +53,29 @@ 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. + + Only matches against actual package tags (the 'tags' field), + not metadata fields like format, architecture, or distro. + Use --format, --arch, and --os for filtering by those fields. + + Args: + pkg: Package dictionary + tag_filter: Tag to match against + + Returns: + True if package matches the tag filter + """ + 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 + + return False + + def resolve_package( owner: str, repo: str, @@ -62,6 +85,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 +99,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 +150,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..ccb073c6 100644 --- a/cloudsmith_cli/core/tests/test_download.py +++ b/cloudsmith_cli/core/tests/test_download.py @@ -151,6 +151,96 @@ 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", + "tags": {"info": ["latest"], "version": ["stable"]}, + }, + { + "name": "test-package", + "version": "0.9.0", + "format": "rpm", + "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 - 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") + + # Test tag from version category - should return v0.9.0 (has "unstable") + result = download.resolve_package( + "owner", "repo", "test-package", tag_filter="unstable" + ) + self.assertEqual(result["version"], "0.9.0") + + # Tag filter should NOT match metadata fields like format + with self.assertRaises(click.ClickException): + download.resolve_package("owner", "repo", "test-package", tag_filter="deb") + + # Tag filter should NOT match metadata fields like architecture + with self.assertRaises(click.ClickException): + download.resolve_package( + "owner", "repo", "test-package", tag_filter="amd64" + ) + + 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")) + + # Test package with empty tags + pkg_empty_tags = {"tags": {}, "format": "rpm"} + self.assertFalse(download._matches_tag_filter(pkg_empty_tags, "latest")) + + # Test matching actual tags + pkg_with_tags = {"tags": {"info": ["test", "upstream"]}} + self.assertTrue(download._matches_tag_filter(pkg_with_tags, "test")) + self.assertTrue(download._matches_tag_filter(pkg_with_tags, "upstream")) + self.assertFalse(download._matches_tag_filter(pkg_with_tags, "nonexistent")) + + # 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 multiple tag categories + pkg_multi_cats = {"tags": {"info": ["upstream"], "version": ["latest"]}} + self.assertTrue(download._matches_tag_filter(pkg_multi_cats, "upstream")) + self.assertTrue(download._matches_tag_filter(pkg_multi_cats, "latest")) + + # Tag filter should NOT match metadata fields + pkg_metadata = { + "format": "deb", + "architectures": [{"name": "arm64"}], + "distro": {"name": "Ubuntu"}, + "distro_version": {"name": "noble"}, + "identifiers": {"deb_component": "main"}, + "tags": {}, + } + self.assertFalse(download._matches_tag_filter(pkg_metadata, "deb")) + self.assertFalse(download._matches_tag_filter(pkg_metadata, "arm64")) + self.assertFalse(download._matches_tag_filter(pkg_metadata, "Ubuntu")) + self.assertFalse(download._matches_tag_filter(pkg_metadata, "noble")) + self.assertFalse(download._matches_tag_filter(pkg_metadata, "main")) + self.assertFalse(download._matches_tag_filter(pkg_metadata, "Ubuntu/noble")) + @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)."""