Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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).
Expand Down
11 changes: 10 additions & 1 deletion cloudsmith_cli/cli/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -78,6 +81,7 @@ def download( # noqa: C901
format_filter,
os_filter,
arch_filter,
tag_filter,
outfile,
overwrite,
all_files,
Expand All @@ -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:

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

Expand Down
49 changes: 49 additions & 0 deletions cloudsmith_cli/core/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,50 @@ 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 (case-sensitive)
# 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_raw = pkg.get("distro", {}).get("name")
distro_version_raw = pkg.get("distro_version", {}).get("name")
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}"

if (
tag_filter == distro_name_raw
or tag_filter == distro_version_raw
or (distro_combo and tag_filter == distro_combo)
):
return True

return False


def resolve_package(
owner: str,
repo: str,
Expand All @@ -62,6 +106,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 +120,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 +171,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
130 changes: 130 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,136 @@ 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 - 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"
) # Only this package has "latest" 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["version"], "1.0.0"
) # Only this package has format "deb"

# 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["version"], "0.9.0"
) # Only this package has arm64 architecture

# 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):
"""Test that only exact name matches are returned (not partial)."""
Expand Down