Skip to content

Commit e898ec1

Browse files
authored
Release v0.2.0
2 parents 906ccd1 + ddc2841 commit e898ec1

14 files changed

Lines changed: 1091 additions & 194 deletions

.bumper.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[tool.bumper]
2-
current_version = "0.1.0"
2+
current_version = "0.2.0"
33
versioning_type = "semver"
44

55
[[tool.bumper.files]]

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ repos:
3636
- id: python-check-blanket-type-ignore
3737
- id: python-use-type-annotations
3838
- repo: https://github.com/astral-sh/ruff-pre-commit
39-
rev: v0.12.9
39+
rev: v0.12.11
4040
hooks:
4141
- id: ruff-check
4242
- repo: https://github.com/rvben/rumdl-pre-commit
43-
rev: v0.0.120
43+
rev: v0.0.130
4444
hooks:
4545
- id: rumdl

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (`<major>`.`<minor>`.`<patch>`)
44

5+
## `[v0.2.0]`
6+
7+
### Changed
8+
* (Internal) Query Gallery API for latest release version prior to download & use for explicit download target
9+
* (Internal) Move CLI from `argparse` to `typer`
10+
11+
### Added
12+
* Add extension version to download filename (e.g. `ms-python.python_2025.13.2025082601.vsix`)
13+
* #2 Add extension cache & management interface
14+
515
## `[v0.1.0]`
616

717
Initial release 🎉

README.md

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,27 +23,53 @@ cog.out(
2323

2424
```text
2525
$ dl_vsix --help
26-
usage: dl_VSIX [-h] [-s SPEC_FILE] [-o OUT_DIR] [-f] [-z] [extension]
26+
Usage: dl_vsix [OPTIONS] COMMAND [ARGS]...
2727
28-
Download VSIX bundles for offline extension installation.
28+
Download VSIX bundles for offline extension installation.
29+
30+
Options:
31+
--help Show this message and exit.
2932
30-
positional arguments:
31-
extension Single extension by ID
32-
33-
options:
34-
-h, --help show this help message and exit
35-
-s, --spec_file SPEC_FILE
36-
JSON-specified collection of extensions
37-
-o, --out_dir OUT_DIR
38-
Download directory (default: ./vsix)
39-
-f, --follow_deps Trace extension's dependencies.
40-
-z, --zip Zip the download extension(s)
33+
Commands:
34+
download Download VSIX extension packages.
35+
cache Package cache utilities
4136
```
4237

4338
<!-- [[[end]]] -->
4439

4540
## Usage
4641

42+
Extension downloads are accomplished using the `dl_vsix download` command:
43+
<!-- [[[cog
44+
import cog
45+
from subprocess import PIPE, run
46+
out = run(["dl_vsix", "download", "--help"], stdout=PIPE, encoding="ascii")
47+
cog.out(
48+
f"\n```text\n$ dl_vsix download --help\n{out.stdout.rstrip()}\n```\n\n"
49+
)
50+
]]] -->
51+
52+
```text
53+
$ dl_vsix download --help
54+
Usage: dl_vsix download [OPTIONS] [EXTENSION_ID]
55+
56+
Download VSIX extension packages.
57+
58+
NOTE: `extension_id` and `spec_file` are mutually exclusive.
59+
60+
Arguments:
61+
[EXTENSION_ID] Single extension by ID
62+
63+
Options:
64+
-s, --spec_file FILE JSON-specified collection of extensions
65+
-o, --out_dir DIRECTORY Download directory [default: vsix]
66+
-f, --follow_deps Trace extension's dependencies
67+
-z, --zip Zip the download extension(s)
68+
--help Show this message and exit.
69+
```
70+
71+
<!-- [[[end]]] -->
72+
4773
### Extension Specification
4874

4975
`dl-vsix` provides two methods for identifying extension(s) to download. Note that these options are mutually exclusive.
@@ -70,3 +96,41 @@ Multiple packages may be specified using a JSON file. Extension IDs are assumed
7096
Each VSIX package should have an `extension/package.json` detailing extension information; if an extension has additional dependencies, they should be declared in an `"extensionDependencies"` field as a list of extension ID strings.
7197

7298
By default, `dl-vsix` will not trace these dependencies. To enable dependency tracing, use the `-f/--follow_deps` flag to trace the dependencies for each download extension & add them to the download queue if any are found.
99+
100+
## Download Caching
101+
102+
`dl-vsix` implements a simple download cache to help prevent repeated downloads of the latest version of an extension. The cache is FIFO based on file modification date, as creation date is not available on all platforms.
103+
104+
By default, this is located in the user's cache directory, as defined by [`platformdirs`](https://platformdirs.readthedocs.io/en/latest/). OS specific location information can be found under the [Platforms API documentation](https://platformdirs.readthedocs.io/en/latest/api.html#platforms). The cache directory can be overridden using the `DL_VSIX_CACHE_DIR` environment variable; note that changing this directory location does not erase existing contents, nor are existing contents transferred to this location.
105+
106+
Cache size defaults to `512` MB, and is configurable using the `DL_VSIX_CACHE_SIZE` environment variable. Cache pruning is only conducted either when the tool initializes, or a new download is inserted into the cache.
107+
108+
### CLI Interface
109+
110+
Cache utilities are accessible via the `dl_vsix cache` command:
111+
<!-- [[[cog
112+
import cog
113+
from subprocess import PIPE, run
114+
out = run(["dl_vsix", "cache", "--help"], stdout=PIPE, encoding="ascii")
115+
cog.out(
116+
f"\n```text\n$ dl_vsix cache --help\n{out.stdout.rstrip()}\n```\n\n"
117+
)
118+
]]] -->
119+
120+
```text
121+
$ dl_vsix cache --help
122+
Usage: dl_vsix cache [OPTIONS] COMMAND [ARGS]...
123+
124+
Package cache utilities
125+
126+
Options:
127+
--help Show this message and exit.
128+
129+
Commands:
130+
info Show cache information.
131+
list List cache contents.
132+
remove Remove extension(s) from cache.
133+
purge Clear package cache.
134+
```
135+
136+
<!-- [[[end]]] -->

dl_vsix/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
APPNAME = "dl-vsix"

dl_vsix/cli.py

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,30 @@
1-
import argparse
21
import json
2+
import os
33
import shutil
44
from pathlib import Path
55

6+
import typer
7+
68
from dl_vsix.dl import Extension, download_extensions
9+
from dl_vsix.extension_cache import DEFAULT_CACHE_MAXSIZE_MB, ExtensionCache
10+
11+
# Initialize package cache in the global scope so we have access to a single instance for the CLI
12+
_cache_path_env = os.getenv("DL_VSIX_CACHE_DIR")
13+
_cache_path_override: Path | None
14+
if _cache_path_env is not None:
15+
_cache_path_override = Path(_cache_path_env)
16+
else:
17+
_cache_path_override = None
18+
19+
_cache_maxsize_env = os.getenv("DL_VSIX_CACHE_DIR")
20+
if _cache_maxsize_env is not None:
21+
_cache_maxsize_override = int(_cache_maxsize_env)
22+
else:
23+
_cache_maxsize_override = DEFAULT_CACHE_MAXSIZE_MB
24+
25+
PACKAGE_CACHE = ExtensionCache(
26+
path_override=_cache_path_override, cache_maxsize_mb=_cache_maxsize_override
27+
)
728

829

930
def _parse_extensions(spec_json: Path) -> list[Extension]:
@@ -30,39 +51,82 @@ def _parse_extensions(spec_json: Path) -> list[Extension]:
3051
return [Extension.from_id(s) for s in spec.get("extensions", [])]
3152

3253

33-
def main() -> None: # noqa: D103
34-
parser = argparse.ArgumentParser(
35-
"dl_VSIX", description="Download VSIX bundles for offline extension installation."
36-
)
37-
source_group = parser.add_mutually_exclusive_group()
38-
source_group.add_argument("extension", nargs="?", type=str, help="Single extension by ID")
39-
source_group.add_argument(
40-
"-s", "--spec_file", type=Path, help="JSON-specified collection of extensions"
41-
)
42-
parser.add_argument(
43-
"-o",
44-
"--out_dir",
45-
type=Path,
46-
default=Path("./vsix"),
47-
help="Download directory (default: ./vsix)",
48-
)
49-
parser.add_argument(
50-
"-f", "--follow_deps", action="store_false", help="Trace extension's dependencies."
51-
)
52-
parser.add_argument("-z", "--zip", action="store_true", help="Zip the download extension(s)")
53-
args = parser.parse_args()
54+
dl_vsix_cli = typer.Typer(
55+
no_args_is_help=True,
56+
add_completion=False,
57+
help="Download VSIX bundles for offline extension installation.",
58+
)
59+
60+
61+
@dl_vsix_cli.command(no_args_is_help=True)
62+
def download(
63+
extension_id: str = typer.Argument("", help="Single extension by ID"),
64+
spec_file: Path = typer.Option(
65+
None, "-s", "--spec_file", help="JSON-specified collection of extensions", dir_okay=False
66+
),
67+
out_dir: Path = typer.Option(
68+
Path("./vsix"), "--out_dir", "-o", help="Download directory", file_okay=False
69+
),
70+
follow_deps: bool = typer.Option(
71+
False, "--follow_deps", "-f", help="Trace extension's dependencies"
72+
),
73+
zip_result: bool = typer.Option(False, "--zip", "-z", help="Zip the download extension(s)"),
74+
) -> None:
75+
"""
76+
Download VSIX extension packages.
77+
78+
NOTE: `extension_id` and `spec_file` are mutually exclusive.
79+
"""
80+
if (not extension_id) and (spec_file is None):
81+
raise ValueError("Either extension_id or spec_file must be specified.")
5482

55-
# Since we have a mutually exclusive group, only one of these can be specified
56-
if args.extension is not None:
57-
extensions = [Extension.from_id(args.extension)]
83+
if extension_id and spec_file:
84+
raise ValueError("Cannot specify both an extension_id and spec file.")
85+
86+
# Options are mutually exclusive, which should be enforced at this point
87+
if extension_id:
88+
extensions = [Extension.from_id(extension_id)]
5889
else:
59-
extensions = _parse_extensions(args.spec_file)
90+
extensions = _parse_extensions(spec_file)
91+
92+
download_extensions(
93+
extensions, out_dir=out_dir, package_cache=PACKAGE_CACHE, follow_dependencies=follow_deps
94+
)
95+
if zip_result:
96+
zip_filepath = out_dir.parent / "zipped_extensions"
97+
shutil.make_archive(base_name=str(zip_filepath), format="zip", root_dir=out_dir)
98+
99+
100+
cache_sub = typer.Typer(
101+
name="cache", no_args_is_help=True, add_completion=False, help="Package cache utilities"
102+
)
103+
dl_vsix_cli.add_typer(cache_sub)
104+
105+
106+
@cache_sub.command("info")
107+
def cache_info() -> None:
108+
"""Show cache information."""
109+
PACKAGE_CACHE.info()
110+
111+
112+
@cache_sub.command("list")
113+
def cache_list() -> None:
114+
"""List cache contents."""
115+
PACKAGE_CACHE.list()
116+
117+
118+
@cache_sub.command("remove")
119+
def cache_remove(extensions: list[str]) -> None:
120+
"""Remove extension(s) from cache."""
121+
for e in extensions:
122+
PACKAGE_CACHE.remove(Extension.from_id(e))
123+
60124

61-
download_extensions(extensions, out_dir=args.out_dir, follow_dependencies=args.follow_deps)
62-
if args.zip:
63-
zip_filepath = args.out_dir.parent / "zipped_extensions"
64-
shutil.make_archive(base_name=zip_filepath, format="zip", root_dir=args.out_dir)
125+
@cache_sub.command("purge")
126+
def cache_purge() -> None:
127+
"""Clear package cache."""
128+
PACKAGE_CACHE.purge()
65129

66130

67131
if __name__ == "__main__":
68-
main()
132+
dl_vsix_cli()

dl_vsix/dl.py

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@
88

99
import httpx
1010

11+
from dl_vsix.extension_cache import ExtensionCache
12+
from dl_vsix.extension_query import query_latest_version
13+
1114

1215
class Extension(t.NamedTuple): # noqa: D101
1316
publisher: str
1417
extension: str
1518

1619
def __str__(self) -> str:
20+
return self.pID
21+
22+
@property
23+
def pID(self) -> str:
24+
"""Build the full extension ID, as `'<publisher>.<package>'`."""
1725
return f"{self.publisher}.{self.extension}"
1826

1927
@classmethod
@@ -27,13 +35,12 @@ def from_id(cls, extension_id: str) -> Extension:
2735
publisher, extension = extension_id.split(".")
2836
return cls(publisher=publisher, extension=extension)
2937

30-
@property
31-
def vsix_query(self) -> str:
38+
def vsix_query(self, version: str = "latest") -> str:
3239
"""Build query URL for the extension's latest VSIX package."""
3340
api_base = f"https://{self.publisher}.gallery.vsassets.io/_apis/public/gallery"
3441
publisher_comp = f"publisher/{self.publisher}"
3542
extension_comp = f"extension/{self.extension}"
36-
suffix = "latest/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
43+
suffix = f"{version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
3744

3845
return f"{api_base}/{publisher_comp}/{extension_comp}/{suffix}"
3946

@@ -65,11 +72,15 @@ def extract_dependencies(vsix_zip: Path, target: str = "extension/package.json")
6572
def download_extensions(
6673
extensions: list[Extension],
6774
out_dir: Path,
75+
package_cache: ExtensionCache,
6876
follow_dependencies: bool = True,
6977
) -> None:
7078
"""
7179
Download VSIX packages for the specified extension(s) from the VS marketplace Gallery API.
7280
81+
Download cache management is provided using an instance of `ExtensionCache` passed to
82+
`package_cache`.
83+
7384
If `follow_dependencies` is `True`, the extension's metadata will be checked to see if it
7485
depends on any additional packages, which will be added to the queue if they haven't yet been
7586
downloaded.
@@ -83,19 +94,29 @@ def download_extensions(
8394
with httpx.Client() as client:
8495
while extensions:
8596
ext = extensions.pop()
86-
out_filepath = out_dir / f"{ext}.vsix"
87-
with client.stream("GET", ext.vsix_query) as r:
88-
if r.status_code == httpx.codes.OK:
89-
with out_filepath.open("wb") as f:
90-
for chunk in r.iter_bytes():
91-
f.write(chunk)
92-
93-
seen_extensions.add(ext)
94-
print(f"Successfully downloaded extension '{ext}'")
95-
96-
else:
97-
print(f"Could not download extension '{ext}': {r.status_code}")
98-
continue
97+
98+
latest_ver = query_latest_version(str(ext))
99+
out_filepath = out_dir / f"{ext}_{latest_ver}.vsix"
100+
101+
cached_ver = package_cache.cached_version(ext)
102+
if (cached_ver is not None) and (cached_ver == latest_ver):
103+
print(f"Cached download for '{ext}' found - version: {cached_ver}")
104+
package_cache.copy_to(ext, out_dir)
105+
else:
106+
with client.stream("GET", ext.vsix_query(version=latest_ver)) as r:
107+
if r.status_code == httpx.codes.OK:
108+
with out_filepath.open("wb") as f:
109+
for chunk in r.iter_bytes():
110+
f.write(chunk)
111+
112+
seen_extensions.add(ext)
113+
print(f"Successfully downloaded extension '{ext}', version: {latest_ver}")
114+
115+
# Add to cache
116+
package_cache.insert(out_filepath)
117+
else:
118+
print(f"Could not download extension '{ext}': {r.status_code}")
119+
continue
99120

100121
if follow_dependencies:
101122
dependencies = extract_dependencies(out_filepath)

0 commit comments

Comments
 (0)