Skip to content

Commit 7dc1064

Browse files
Add --tag option to download command (#249)
* Add --tag option to download command * Add download command in Readme * Fix tag filtering consistency and add comprehensive tests * Update README.md * add integration tests for tag filtering
1 parent c7eabc7 commit 7dc1064

File tree

6 files changed

+225
-2
lines changed

6 files changed

+225
-2
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
77

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

1012
## [1.13.0] - 2026-02-16
1113

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ The CLI currently supports the following commands (and sub-commands):
3232
- `delete`|`rm`: Delete a package from a repository.
3333
- `dependencies`|`deps`: List direct (non-transitive) dependencies for a package.
3434
- `docs`: Launch the help website in your browser.
35+
- `download`: Download a package from a repository.
3536
- `entitlements`|`ents`: Manage the entitlements for a repository.
3637
- `create`|`new`: Create a new entitlement in a repository.
3738
- `delete`|`rm`: Delete an entitlement from a repository.
@@ -250,6 +251,45 @@ cloudsmith push rpm --help
250251
```
251252

252253

254+
## Downloading Packages
255+
256+
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.
257+
258+
For example, to download a specific package:
259+
260+
```
261+
cloudsmith download your-account/your-repo package-name
262+
```
263+
264+
You can filter by various attributes like version, format, architecture, operating system, and tags:
265+
266+
```
267+
# Download a specific version
268+
cloudsmith download your-account/your-repo package-name --version 1.2.3
269+
270+
# Filter by format and architecture
271+
cloudsmith download your-account/your-repo package-name --format deb --arch amd64
272+
273+
# Filter by package tag (e.g., latest, stable, beta)
274+
cloudsmith download your-account/your-repo package-name --tag latest
275+
276+
# Combine tag with metadata filters
277+
cloudsmith download your-account/your-repo package-name --tag stable --format deb --arch arm64
278+
279+
# Download all associated files (POM, sources, javadoc, etc.)
280+
cloudsmith download your-account/your-repo package-name --all-files
281+
282+
# Preview what would be downloaded without actually downloading
283+
cloudsmith download your-account/your-repo package-name --dry-run
284+
```
285+
286+
For more advanced usage and all available options:
287+
288+
```
289+
cloudsmith download --help
290+
```
291+
292+
253293
## Contributing
254294

255295
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).

cloudsmith_cli/cli/commands/download.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
@click.option(
4343
"--arch", "arch_filter", help="Architecture filter (e.g., 'amd64', 'arm64')."
4444
)
45+
@click.option(
46+
"--tag",
47+
"tag_filter",
48+
help="Filter by package tag (e.g., 'latest', 'stable'). Use --format, --arch, --os for metadata filters.",
49+
)
4550
@click.option(
4651
"--outfile",
4752
type=click.Path(),
@@ -78,6 +83,7 @@ def download( # noqa: C901
7883
format_filter,
7984
os_filter,
8085
arch_filter,
86+
tag_filter,
8187
outfile,
8288
overwrite,
8389
all_files,
@@ -88,7 +94,7 @@ def download( # noqa: C901
8894
Download a package from a Cloudsmith repository.
8995
9096
This command downloads a package binary from a Cloudsmith repository. You can
91-
filter packages by version, format, operating system, and architecture.
97+
filter packages by version, format, operating system, architecture, and tags.
9298
9399
Examples:
94100
@@ -104,6 +110,10 @@ def download( # noqa: C901
104110
# Download with filters and custom output name
105111
cloudsmith download myorg/myrepo mypackage --format deb --arch amd64 --outfile my-package.deb
106112
113+
\b
114+
# Download a package with a specific tag
115+
cloudsmith download myorg/myrepo mypackage --tag latest
116+
107117
\b
108118
# Download all associated files (POM, sources, javadoc, etc.) for a Maven/NuGet package
109119
cloudsmith download myorg/myrepo mypackage --all-files
@@ -150,6 +160,7 @@ def download( # noqa: C901
150160
format_filter=format_filter,
151161
os_filter=os_filter,
152162
arch_filter=arch_filter,
163+
tag_filter=tag_filter,
153164
yes=yes,
154165
)
155166

cloudsmith_cli/cli/tests/commands/test_download.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,59 @@
99

1010

1111
class TestDownloadCommand(unittest.TestCase):
12-
"""Test the download CLI command."""
12+
@patch("cloudsmith_cli.core.download.list_packages")
13+
@patch("cloudsmith_cli.cli.commands.download.resolve_auth")
14+
def test_download_with_tag_filter_integration(
15+
self, mock_resolve_auth, mock_list_packages
16+
):
17+
"""Integration test: download command with --tag filter (end-to-end)."""
18+
mock_session = Mock()
19+
mock_resolve_auth.return_value = (mock_session, {}, "none")
20+
21+
# Simulate two packages, only one matches the tag
22+
mock_packages = [
23+
{
24+
"name": "test-package",
25+
"version": "1.0.0",
26+
"format": "deb",
27+
"tags": {"info": ["latest", "beta"]},
28+
"filename": "test-package_1.0.0.deb",
29+
"cdn_url": "https://example.com/test-package_1.0.0.deb",
30+
"size": 1024,
31+
},
32+
{
33+
"name": "test-package",
34+
"version": "0.9.0",
35+
"format": "deb",
36+
"tags": {"info": ["beta"]},
37+
"filename": "test-package_0.9.0.deb",
38+
"cdn_url": "https://example.com/test-package_0.9.0.deb",
39+
"size": 512,
40+
},
41+
]
42+
mock_page_info = Mock()
43+
mock_page_info.is_valid = True
44+
mock_page_info.page = 1
45+
mock_page_info.page_total = 1
46+
mock_list_packages.return_value = (mock_packages, mock_page_info)
47+
48+
runner = CliRunner()
49+
result = runner.invoke(
50+
download,
51+
[
52+
"--config-file",
53+
"/dev/null",
54+
"testorg/testrepo",
55+
"test-package",
56+
"--tag",
57+
"latest",
58+
"--dry-run",
59+
],
60+
)
61+
62+
self.assertEqual(result.exit_code, 0)
63+
self.assertIn("test-package v1.0.0", result.output)
64+
self.assertNotIn("test-package v0.9.0", result.output)
1365

1466
def setUp(self):
1567
self.runner = CliRunner()

cloudsmith_cli/core/download.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,29 @@ def resolve_auth(
5353
return session, headers, auth_source
5454

5555

56+
def _matches_tag_filter(pkg: Dict, tag_filter: str) -> bool:
57+
"""
58+
Check if a package matches the tag filter.
59+
60+
Only matches against actual package tags (the 'tags' field),
61+
not metadata fields like format, architecture, or distro.
62+
Use --format, --arch, and --os for filtering by those fields.
63+
64+
Args:
65+
pkg: Package dictionary
66+
tag_filter: Tag to match against
67+
68+
Returns:
69+
True if package matches the tag filter
70+
"""
71+
pkg_tags = pkg.get("tags", {})
72+
for tag_category in pkg_tags.values():
73+
if isinstance(tag_category, list) and tag_filter in tag_category:
74+
return True
75+
76+
return False
77+
78+
5679
def resolve_package(
5780
owner: str,
5881
repo: str,
@@ -62,6 +85,7 @@ def resolve_package(
6285
format_filter: Optional[str] = None,
6386
os_filter: Optional[str] = None,
6487
arch_filter: Optional[str] = None,
88+
tag_filter: Optional[str] = None,
6589
yes: bool = False,
6690
) -> Dict:
6791
"""
@@ -75,6 +99,7 @@ def resolve_package(
7599
format_filter: Optional format filter
76100
os_filter: Optional OS filter
77101
arch_filter: Optional architecture filter
102+
tag_filter: Optional tag filter
78103
yes: If True, automatically select best match when multiple found
79104
80105
Returns:
@@ -125,6 +150,9 @@ def resolve_package(
125150
# Apply architecture filter
126151
if arch_filter and pkg.get("architecture") != arch_filter:
127152
continue
153+
# Apply tag filter
154+
if tag_filter and not _matches_tag_filter(pkg, tag_filter):
155+
continue
128156
filtered_packages.append(pkg)
129157
packages = filtered_packages
130158

cloudsmith_cli/core/tests/test_download.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,96 @@ def test_resolve_package_with_filters(self, mock_list_packages):
151151
page_size=100,
152152
)
153153

154+
@patch("cloudsmith_cli.core.download.list_packages")
155+
def test_resolve_package_with_tag_filter(self, mock_list_packages):
156+
"""Test package resolution with tag filter."""
157+
mock_packages = [
158+
{
159+
"name": "test-package",
160+
"version": "1.0.0",
161+
"format": "deb",
162+
"tags": {"info": ["latest"], "version": ["stable"]},
163+
},
164+
{
165+
"name": "test-package",
166+
"version": "0.9.0",
167+
"format": "rpm",
168+
"tags": {"info": ["beta"], "version": ["unstable"]},
169+
},
170+
]
171+
mock_page_info = Mock()
172+
mock_page_info.is_valid = True
173+
mock_page_info.page = 1
174+
mock_page_info.page_total = 1
175+
mock_list_packages.return_value = (mock_packages, mock_page_info)
176+
177+
# Test actual tag filtering - should return v1.0.0 (has "latest" tag)
178+
result = download.resolve_package(
179+
"owner", "repo", "test-package", tag_filter="latest"
180+
)
181+
self.assertEqual(result["version"], "1.0.0")
182+
183+
# Test tag from version category - should return v0.9.0 (has "unstable")
184+
result = download.resolve_package(
185+
"owner", "repo", "test-package", tag_filter="unstable"
186+
)
187+
self.assertEqual(result["version"], "0.9.0")
188+
189+
# Tag filter should NOT match metadata fields like format
190+
with self.assertRaises(click.ClickException):
191+
download.resolve_package("owner", "repo", "test-package", tag_filter="deb")
192+
193+
# Tag filter should NOT match metadata fields like architecture
194+
with self.assertRaises(click.ClickException):
195+
download.resolve_package(
196+
"owner", "repo", "test-package", tag_filter="amd64"
197+
)
198+
199+
def test_matches_tag_filter_edge_cases(self):
200+
"""Test _matches_tag_filter function with edge cases."""
201+
202+
# Test package without tags field
203+
pkg_no_tags = {"name": "test", "format": "deb"}
204+
self.assertFalse(download._matches_tag_filter(pkg_no_tags, "latest"))
205+
206+
# Test package with empty tags
207+
pkg_empty_tags = {"tags": {}, "format": "rpm"}
208+
self.assertFalse(download._matches_tag_filter(pkg_empty_tags, "latest"))
209+
210+
# Test matching actual tags
211+
pkg_with_tags = {"tags": {"info": ["test", "upstream"]}}
212+
self.assertTrue(download._matches_tag_filter(pkg_with_tags, "test"))
213+
self.assertTrue(download._matches_tag_filter(pkg_with_tags, "upstream"))
214+
self.assertFalse(download._matches_tag_filter(pkg_with_tags, "nonexistent"))
215+
216+
# Test case-sensitive matching for actual tags
217+
pkg_case_tags = {"tags": {"info": ["Latest", "Beta"]}}
218+
self.assertTrue(download._matches_tag_filter(pkg_case_tags, "Latest"))
219+
self.assertFalse(
220+
download._matches_tag_filter(pkg_case_tags, "latest")
221+
) # case mismatch
222+
223+
# Test multiple tag categories
224+
pkg_multi_cats = {"tags": {"info": ["upstream"], "version": ["latest"]}}
225+
self.assertTrue(download._matches_tag_filter(pkg_multi_cats, "upstream"))
226+
self.assertTrue(download._matches_tag_filter(pkg_multi_cats, "latest"))
227+
228+
# Tag filter should NOT match metadata fields
229+
pkg_metadata = {
230+
"format": "deb",
231+
"architectures": [{"name": "arm64"}],
232+
"distro": {"name": "Ubuntu"},
233+
"distro_version": {"name": "noble"},
234+
"identifiers": {"deb_component": "main"},
235+
"tags": {},
236+
}
237+
self.assertFalse(download._matches_tag_filter(pkg_metadata, "deb"))
238+
self.assertFalse(download._matches_tag_filter(pkg_metadata, "arm64"))
239+
self.assertFalse(download._matches_tag_filter(pkg_metadata, "Ubuntu"))
240+
self.assertFalse(download._matches_tag_filter(pkg_metadata, "noble"))
241+
self.assertFalse(download._matches_tag_filter(pkg_metadata, "main"))
242+
self.assertFalse(download._matches_tag_filter(pkg_metadata, "Ubuntu/noble"))
243+
154244
@patch("cloudsmith_cli.core.download.list_packages")
155245
def test_resolve_package_exact_name_match(self, mock_list_packages):
156246
"""Test that only exact name matches are returned (not partial)."""

0 commit comments

Comments
 (0)