typvend 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
typvend/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """typvend - Typst Package Vendoring CLI.
2
+
3
+ A robust Python utility to scan Typst files and vendor required preview
4
+ packages locally for offline usage.
5
+ """
6
+
7
+ __version__ = "0.1.0"
typvend/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for executing the package with python -m typvend."""
2
+
3
+ from typvend.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
typvend/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """Command-line interface for the typvend tool.
2
+
3
+ This module sets up the argument parser, handles the subcommands 'add' and 'scan',
4
+ and configures logging.
5
+ """
6
+
7
+ import argparse
8
+ import logging
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import niquests
14
+ import platformdirs
15
+
16
+ from typvend.downloader import download_package
17
+ from typvend.index import resolve_latest_version
18
+ from typvend.scanner import scan_path
19
+
20
+ logger = logging.getLogger("typvend")
21
+
22
+
23
+ def get_default_output() -> Path:
24
+ """Returns the platform-specific default Typst package directory.
25
+
26
+ Returns:
27
+ A Path object pointing to the system package directory.
28
+ """
29
+ return platformdirs.user_data_path("typst") / "packages"
30
+
31
+
32
+ def parse_package_arg(pkg: str) -> tuple[str, str]:
33
+ """Parses a package argument in format name[@version].
34
+
35
+ Args:
36
+ pkg: A string of the form "name" or "name@version".
37
+
38
+ Returns:
39
+ A tuple (name, version) where version is "latest" if not specified.
40
+
41
+ Raises:
42
+ ValueError: If the package name is empty or contains invalid characters.
43
+ """
44
+ if "@" in pkg:
45
+ parts = pkg.split("@", 1)
46
+ name = parts[0]
47
+ version = parts[1] or "latest"
48
+ else:
49
+ name = pkg
50
+ version = "latest"
51
+
52
+ if not name or not re.fullmatch(r"[a-zA-Z0-9_-]+", name):
53
+ msg = f"Invalid package name: '{name}'. Only alphanumeric, hyphens, underscores allowed."
54
+ raise ValueError(msg)
55
+
56
+ return name, version
57
+
58
+
59
+ def handle_add(args: argparse.Namespace) -> int:
60
+ """Handles the 'add' subcommand.
61
+
62
+ Args:
63
+ args: Parsed command-line arguments.
64
+
65
+ Returns:
66
+ 0 if all packages were successfully vendored, 1 otherwise.
67
+ """
68
+ namespace: str = args.namespace
69
+ output_dir = Path(args.output) if args.output else get_default_output()
70
+ force: bool = args.force
71
+
72
+ failed = False
73
+
74
+ # Type refinement
75
+ packages: list[str] = args.packages
76
+
77
+ for pkg_arg in packages:
78
+ name, version = parse_package_arg(pkg_arg)
79
+ try:
80
+ if version == "latest":
81
+ logger.info("Resolving latest version for %s...", name)
82
+ version = resolve_latest_version(name, namespace)
83
+ logger.info("Latest version resolved to %s", version)
84
+
85
+ download_package(
86
+ name=name,
87
+ version=version,
88
+ output_dir=output_dir,
89
+ namespace=namespace,
90
+ force=force,
91
+ )
92
+ except (ValueError, TypeError, niquests.RequestException, OSError):
93
+ failed = True
94
+ logger.error("Error vendoring package '%s'", pkg_arg, exc_info=args.verbose)
95
+
96
+ return 1 if failed else 0
97
+
98
+
99
+ def handle_scan(args: argparse.Namespace) -> int:
100
+ """Handles the 'scan' subcommand.
101
+
102
+ Args:
103
+ args: Parsed command-line arguments.
104
+
105
+ Returns:
106
+ 0 if all discovered packages were successfully vendored, 1 otherwise.
107
+ """
108
+ namespace: str = args.namespace
109
+ output_dir = Path(args.output) if args.output else get_default_output()
110
+ force: bool = args.force
111
+ scan_target = Path(args.path)
112
+
113
+ if not scan_target.exists():
114
+ logger.error("Scan path does not exist: %s", scan_target)
115
+ return 1
116
+
117
+ logger.info("Scanning %s for package imports...", scan_target)
118
+ packages = scan_path(scan_target, namespace)
119
+ logger.info("Discovered %d package(s): %s", len(packages), packages)
120
+
121
+ if not packages:
122
+ logger.info("No packages found to vendor.")
123
+ return 0
124
+
125
+ failed = False
126
+ for name, version in sorted(packages):
127
+ try:
128
+ download_package(
129
+ name=name,
130
+ version=version,
131
+ output_dir=output_dir,
132
+ namespace=namespace,
133
+ force=force,
134
+ )
135
+ except (ValueError, TypeError, niquests.RequestException, OSError):
136
+ failed = True
137
+ logger.error("Error vendoring package '%s:%s'", name, version, exc_info=args.verbose)
138
+
139
+ return 1 if failed else 0
140
+
141
+
142
+ def main() -> None:
143
+ """Main entry point for the CLI."""
144
+ parent_parser = argparse.ArgumentParser(add_help=False)
145
+ parent_parser.add_argument(
146
+ "-o", "--output", help="Custom output directory for vendored packages"
147
+ )
148
+ parent_parser.add_argument(
149
+ "--namespace",
150
+ default="preview",
151
+ help="Package namespace (default: preview)",
152
+ )
153
+ parent_parser.add_argument(
154
+ "-f",
155
+ "--force",
156
+ action="store_true",
157
+ help="Re-download package even if destination already exists",
158
+ )
159
+ parent_parser.add_argument(
160
+ "-v",
161
+ "--verbose",
162
+ action="store_true",
163
+ help="Enable verbose output logging",
164
+ )
165
+
166
+ parser = argparse.ArgumentParser(description="typvend — Typst Package Vendoring CLI")
167
+ subparsers = parser.add_subparsers(dest="command", required=True)
168
+
169
+ add_parser = subparsers.add_parser(
170
+ "add", parents=[parent_parser], help="Add explicit package(s) by name"
171
+ )
172
+ add_parser.add_argument(
173
+ "packages",
174
+ nargs="+",
175
+ help="Package name(s) optionally with version (e.g. fontawesome or fontawesome@0.6.0)",
176
+ )
177
+
178
+ scan_parser = subparsers.add_parser(
179
+ "scan",
180
+ parents=[parent_parser],
181
+ help="Scan files/directories and vendor all discovered package imports",
182
+ )
183
+ scan_parser.add_argument("path", help="Path to file or directory to scan for imports")
184
+
185
+ args = parser.parse_args()
186
+
187
+ # Configure logging
188
+ log_level = logging.INFO if args.verbose else logging.WARNING
189
+ logging.basicConfig(
190
+ level=log_level,
191
+ format="%(levelname)s: %(message)s",
192
+ )
193
+ logger.setLevel(log_level)
194
+
195
+ if args.command == "add":
196
+ code = handle_add(args)
197
+ elif args.command == "scan":
198
+ code = handle_scan(args)
199
+ else:
200
+ code = 1
201
+
202
+ sys.exit(code)
typvend/downloader.py ADDED
@@ -0,0 +1,88 @@
1
+ """Module for downloading and extracting Typst packages.
2
+
3
+ This module provides functions to fetch a package tarball from the Typst
4
+ repository, verify its paths to prevent directory traversal, and extract
5
+ its contents to the local vendor directory.
6
+ """
7
+
8
+ import io
9
+ import logging
10
+ import sys
11
+ import tarfile
12
+ from pathlib import Path
13
+
14
+ import niquests
15
+
16
+ from typvend import __version__
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def download_package(
22
+ name: str,
23
+ version: str,
24
+ output_dir: Path,
25
+ namespace: str = "preview",
26
+ *,
27
+ force: bool = False,
28
+ ) -> bool:
29
+ """Downloads and extracts a Typst package to the local directory.
30
+
31
+ Args:
32
+ name: The name of the package.
33
+ version: The package version (e.g. "0.6.0").
34
+ output_dir: The base vendor output directory.
35
+ namespace: The namespace of the package (e.g. "preview").
36
+ force: If True, overwrite the package even if it already exists.
37
+
38
+ Returns:
39
+ True if the package was downloaded and extracted, False if it was
40
+ skipped because it already exists and force was False.
41
+
42
+ Raises:
43
+ ValueError: If a directory traversal is detected in the tarball.
44
+ niquests.RequestException: If the package download fails.
45
+ """
46
+ dest_dir = output_dir / namespace / name / version
47
+ if dest_dir.exists() and not force:
48
+ logger.info(
49
+ "Skipping package %s:%s (already exists at %s)",
50
+ name,
51
+ version,
52
+ dest_dir,
53
+ )
54
+ return False
55
+
56
+ url = f"https://packages.typst.org/{namespace}/{name}-{version}.tar.gz"
57
+ logger.info("Downloading %s...", url)
58
+ headers = {"User-Agent": f"typvend/{__version__}"}
59
+
60
+ try:
61
+ response = niquests.get(url, headers=headers, timeout=30)
62
+ response.raise_for_status()
63
+ content = response.content
64
+ if not content:
65
+ msg = f"Failed to download package: no content received from {url}"
66
+ raise ValueError(msg)
67
+ except niquests.RequestException as e:
68
+ logger.error("Failed to download package %s:%s: %s", name, version, e)
69
+ raise
70
+
71
+ logger.info("Extracting %s to %s...", name, dest_dir)
72
+ dest_dir.mkdir(parents=True, exist_ok=True)
73
+ dest_abs = dest_dir.resolve()
74
+
75
+ tar_data = io.BytesIO(content)
76
+ with tarfile.open(fileobj=tar_data, mode="r:gz") as tar:
77
+ if sys.version_info >= (3, 12):
78
+ tar.extractall(path=dest_dir, filter="data")
79
+ else:
80
+ for member in tar.getmembers():
81
+ member_path = (dest_dir / member.name).resolve()
82
+ if dest_abs not in member_path.parents and member_path != dest_abs:
83
+ msg = f"Attempted directory traversal in tarball: {member.name}"
84
+ raise ValueError(msg)
85
+ tar.extractall(path=dest_dir)
86
+
87
+ logger.info("Successfully vendored %s:%s", name, version)
88
+ return True
typvend/index.py ADDED
@@ -0,0 +1,98 @@
1
+ """Module for fetching and parsing the Typst packages index.
2
+
3
+ This module provides functions to fetch the official package list and resolve
4
+ the latest version for any given package name.
5
+ """
6
+
7
+ from typing import Any
8
+
9
+ import niquests
10
+
11
+ from typvend import __version__
12
+
13
+ # In-memory cache for index.json requests. Key is namespace.
14
+ _INDEX_CACHE: dict[str, list[dict[str, Any]]] = {}
15
+
16
+
17
+ def fetch_index(namespace: str = "preview") -> list[dict[str, Any]]:
18
+ """Fetches the Typst package index for the given namespace.
19
+
20
+ Args:
21
+ namespace: The package namespace, e.g. "preview".
22
+
23
+ Returns:
24
+ A list of dictionaries containing package metadata.
25
+
26
+ Raises:
27
+ ValueError: If the response cannot be parsed or is invalid.
28
+ """
29
+ if namespace in _INDEX_CACHE:
30
+ return _INDEX_CACHE[namespace]
31
+
32
+ url = f"https://packages.typst.org/{namespace}/index.json"
33
+ headers = {"User-Agent": f"typvend/{__version__}"}
34
+
35
+ try:
36
+ response = niquests.get(url, headers=headers, timeout=15)
37
+ response.raise_for_status()
38
+ data = response.json()
39
+ except niquests.RequestException as e:
40
+ msg = f"Failed to fetch package index from {url}: {e}"
41
+ raise ValueError(msg) from e
42
+
43
+ if not isinstance(data, list):
44
+ msg = f"Expected index.json to be a list, got {type(data)}"
45
+ raise TypeError(msg)
46
+
47
+ _INDEX_CACHE[namespace] = data
48
+ return data
49
+
50
+
51
+ def parse_semver(version_str: str) -> tuple[int, int, int] | None:
52
+ """Parses a semver version string into a tuple of integers.
53
+
54
+ Args:
55
+ version_str: A string of the form "major.minor.patch".
56
+
57
+ Returns:
58
+ A tuple (major, minor, patch), or None if the version is invalid.
59
+ """
60
+ parts = version_str.split(".")
61
+ if len(parts) != 3: # noqa: PLR2004
62
+ return None
63
+ try:
64
+ return int(parts[0]), int(parts[1]), int(parts[2])
65
+ except ValueError:
66
+ return None
67
+
68
+
69
+ def resolve_latest_version(pkg_name: str, namespace: str = "preview") -> str:
70
+ """Resolves the latest version of a package from the Typst packages index.
71
+
72
+ Args:
73
+ pkg_name: The name of the package.
74
+ namespace: The namespace to search.
75
+
76
+ Returns:
77
+ The latest version string (e.g. "0.6.0").
78
+
79
+ Raises:
80
+ ValueError: If the package is not found in the index.
81
+ TypeError: If the upstream index response has an unexpected type.
82
+ """
83
+ index_data = fetch_index(namespace)
84
+ versions: list[tuple[tuple[int, int, int], str]] = []
85
+ for pkg in index_data:
86
+ if pkg.get("name") == pkg_name:
87
+ version = pkg.get("version")
88
+ if isinstance(version, str):
89
+ parsed = parse_semver(version)
90
+ if parsed is not None:
91
+ versions.append((parsed, version))
92
+
93
+ if not versions:
94
+ msg = f"Package '{pkg_name}' not found in namespace '{namespace}'"
95
+ raise ValueError(msg)
96
+
97
+ versions.sort(key=lambda v: v[0])
98
+ return versions[-1][1]
typvend/scanner.py ADDED
@@ -0,0 +1,62 @@
1
+ """Module for scanning Typst files to find imported packages.
2
+
3
+ This module provides functionality to scan single .typ files or recursively
4
+ scan directories to identify all referenced packages in a given namespace.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def scan_file(file_path: Path, namespace: str = "preview") -> set[tuple[str, str]]:
15
+ """Scans a single file for package imports in the given namespace.
16
+
17
+ Matches imports like `@<namespace>/<pkg>:<version>`.
18
+
19
+ Args:
20
+ file_path: The path of the file to scan.
21
+ namespace: The namespace to search for (e.g. "preview").
22
+
23
+ Returns:
24
+ A set of tuples (package_name, version).
25
+ """
26
+ found: set[tuple[str, str]] = set()
27
+ pattern = re.compile(rf"@{re.escape(namespace)}/([a-zA-Z0-9_-]+):(\d+\.\d+\.\d+)")
28
+
29
+ try:
30
+ content = file_path.read_text(encoding="utf-8", errors="ignore")
31
+ except OSError as e:
32
+ logger.warning("Could not read file %s: %s", file_path, e)
33
+ return found
34
+
35
+ for match in pattern.finditer(content):
36
+ pkg_name = match.group(1)
37
+ version = match.group(2)
38
+ found.add((pkg_name, version))
39
+
40
+ return found
41
+
42
+
43
+ def scan_path(path: Path, namespace: str = "preview") -> set[tuple[str, str]]:
44
+ """Scans a file or recursively scans a directory for package imports.
45
+
46
+ Args:
47
+ path: Path to a file or directory.
48
+ namespace: The namespace to search for (e.g. "preview").
49
+
50
+ Returns:
51
+ A set of tuples (package_name, version).
52
+ """
53
+ found: set[tuple[str, str]] = set()
54
+
55
+ if path.is_file():
56
+ return scan_file(path, namespace)
57
+
58
+ if path.is_dir():
59
+ for file in path.rglob("*.typ"):
60
+ found.update(scan_file(file, namespace))
61
+
62
+ return found
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: typvend
3
+ Version: 0.1.0
4
+ Summary: Typst Package Vendoring CLI
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Diego Alvarez S.
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ License-File: LICENSE
27
+ Requires-Python: >=3.11
28
+ Requires-Dist: niquests>=3.18.0
29
+ Requires-Dist: platformdirs>=4.9
30
+ Description-Content-Type: text/markdown
31
+
32
+ # typvend — Typst Package Vendoring CLI
33
+
34
+ `typvend` is a lightweight Python CLI utility designed to vendor official Typst packages locally for offline development, sandboxed builds, or containerized production CI/CD workflows.
35
+
36
+ ## Why?
37
+
38
+ Typst downloads packages on the fly at compile time with no official way to pre-download them.
39
+ This can be problematic for offline compilation and read-only production environments (like containers).
40
+ The solution is to either run the compilation once to fetch the packages or download them manually.
41
+
42
+ `typvend` simplifies this, downloading packages to the default Typst cache path or any directory you choose (then point Typst to it via `--package-cache-path`), in two ways:
43
+ - **Explicit:** `add <pkg>[@<version>]` — download specific packages by name, with a version or `@latest`.
44
+ - **Scan:** recursively find all `@preview/<pkg>:<version>` imports in `.typ` files and vendor them in one go.
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ # Install and run instantly using uvx / pipx
50
+ uvx typvend --help
51
+ ```
52
+
53
+ Global options:
54
+ - `-o`, `--output DIR` — Custom directory to extract packages (defaults to native OS Typst search path).
55
+ - `--namespace NS` — Custom namespace (defaults to `preview`).
56
+ - `-f`, `--force` — Re-download package even if it already exists.
57
+ - `-v`, `--verbose` — Enable verbose output logs.
58
+
59
+ ### 1. Adding Packages Explicitly
60
+
61
+ ```bash
62
+ # Download latest version of fontawesome
63
+ uvx typvend add fontawesome
64
+
65
+ # Download specific versions
66
+ uvx typvend add fontawesome@0.5.0 cetz
67
+ ```
68
+
69
+ ### 2. Scanning Project Directories
70
+
71
+ Recursively searches a file or directory for package imports and vendors all discovered packages in one command:
72
+
73
+ ```bash
74
+ # Scan a templates directory and output packages to typst cache folder
75
+ uvx typvend scan ./templates
76
+
77
+ # Scan and output to a custom directory (e.g. for Docker cache stages)
78
+ uvx typvend scan ./templates --output /typst-packages
79
+ ```
@@ -0,0 +1,11 @@
1
+ typvend/__init__.py,sha256=RPIBAOSE283x37HKTTCYw5j99UkxQu5Ct7ODMiwNdtA,178
2
+ typvend/__main__.py,sha256=k60UlPZkYvsMv1yayg6Cn6_iBUHL5FAkmCel_PtHH9k,137
3
+ typvend/cli.py,sha256=059GD-lVh3n1Pn3YGvacTvj3J5COUy5vGOoSd3y2jrw,5888
4
+ typvend/downloader.py,sha256=zx_OsybLergWRYLrjNpgDan899sQB5cc0IZ_uLFqUmc,2902
5
+ typvend/index.py,sha256=rai9B873qYTpd-wS_Mv0mK6WJcZSGMLkI03lNzW0P2w,2977
6
+ typvend/scanner.py,sha256=osFxJvqGNkyOv-qwQQChLAieo3IRv3R8M5O63gl8Nio,1796
7
+ typvend-0.1.0.dist-info/METADATA,sha256=KfJ6RFuxB9f2HqsdU22xMjMUFbc17lQcIHODMLPZSbo,3388
8
+ typvend-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ typvend-0.1.0.dist-info/entry_points.txt,sha256=7n2Xt5tKAGDXJ1Z2qoocCrINRroJ9MhNkYK77q8Tyks,45
10
+ typvend-0.1.0.dist-info/licenses/LICENSE,sha256=Y4U8gNrx2iAql71iNTZWdpNzYWBvoF0KZKVTM_cCfHQ,1073
11
+ typvend-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ typvend = typvend.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Diego Alvarez S.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.