Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
- Added `--tag` option to `download` command for filtering packages by tags
- Added download command documentation to README with comprehensive usage examples

## [1.13.0] - 2026-02-16

Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -250,6 +251,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).
Expand Down
13 changes: 12 additions & 1 deletion cloudsmith_cli/cli/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -78,6 +83,7 @@ def download( # noqa: C901
format_filter,
os_filter,
arch_filter,
tag_filter,
outfile,
overwrite,
all_files,
Expand All @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down
54 changes: 53 additions & 1 deletion cloudsmith_cli/cli/tests/commands/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions cloudsmith_cli/core/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
"""
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
90 changes: 90 additions & 0 deletions cloudsmith_cli/core/tests/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down